Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Advanced Reactivity API #22

Closed
wants to merge 3 commits into from
Closed

Advanced Reactivity API #22

wants to merge 3 commits into from

Conversation

yyx990803
Copy link
Member

Rendered

API for creating and observing standalone reactive values outside components.

import { state, value, computed, watch } from '@vue/observer'

// reactive object
// equivalent of 2.x Vue.observable()
const obj = state({ a: 1 })

// watch with a getter function
watch(() => obj.a, value => {
  console.log(`obj.a is: ${value}`)
})

// a "ref" object that has a .value property
const count = value(0)

// computed "ref" with a read-only .value property
const plusOne = computed(() => count.value + 1)

// refs can be watched directly
watch(count, (count, oldCount) => {
  console.log(`count is: ${count}`)
})

watch(plusOne, countPlusOne => {
  console.log(`count plus one is: ${countPlusOne}`)
})

@yyx990803 yyx990803 added 3.x This RFC only targets 3.0 and above core labels Mar 26, 2019
@skyrpex
Copy link

skyrpex commented Mar 26, 2019

The watch parameters difference between a reactive object and a ref object is a bit confusing. Maybe state and value could work seamlessly in some way?

@Aaron-Pool
Copy link

I, personally, would like to see some heavy warning verbiage around many of these features, as well as some "don't use this in 'x' scenario, just use simpler option 'y'" examples. I think that would go a long way towards helping newer developers get a good grasp of when and when not to use this level of the api. A lot of these apis look like they could result in some real spaghetti architecture if perceived as "first-choice" candidates for state management. As someone who has experienced some angular.js pre-1.5.x trauma, I'd certainly like to avoid that.

@crutchcorn
Copy link

If $watch was removed in favor of the import, would computed still rely on it under-the-hood?
If so, I'd be curious to hear how many applications use Vue without watch or computed props

Otherwise, I think that removing $watch would be worth it for the tree shaking capabilities alone

@LinusBorg
Copy link
Member

@skyrpex

What in particular do you find confusing? I assume it's that you for areactive object you would do something like () => ob.a(accessing a property) while you can simply pass return a ref without having to doref.value`?

I see how this could trip up some people, and in fact, () => ref.value would work just as well.

But since we can recognize that ref is a ref object, we can infer that the only sensible thing to do is to watch it's value prop, which allows us to provide users the shortcut of just passing the ref directly.

We can't do the same for "normal" reactive objects, so in their case, we have to provide a function to actually access the property(s) to watch.

Do you think it would be fine if we document the getter function way for both first, and then explicitly document directly passing a ref as a shortcut?

@LinusBorg
Copy link
Member

@Aaron-Pool We definitely agree. We called this RFC Advanced Reactivity API for that reason, basically.

The plan would be to thoroughly extend the Reactivity in depth chapter that we have in our current docs with documentation for these advanced APIs as well as useage guidelines.

@LinusBorg
Copy link
Member

@crutchcorn

$watch would be a wrapper around the standalone watch(), and computed properties would also internally rely on watch(), so removing the component APIs $watch wouldn't break or change anything in that regard.

@thedamon
Copy link

I don't have much experience with them, but this looks like it could make writing render functions feel a lot more 'native' to Vue. (currently it feels a little bit like its own language). Is that an accurate assessment?

I have a niggling concern about all this refs potentially becoming confusing in relation to this.$refs.. might it make sense to rename the latter to this.$domRefs (and potentially keep the old one as a deprecated alias for backward compatability?) (this potential confusion would likely predate this particular proposal, though)

@skyrpex
Copy link

skyrpex commented Mar 26, 2019

What in particular do you find confusing?

Exactly what you mentioned (sorry, I was on mobile so I couldn't expand much). Basically, this:

const count = value(0)
watch(count, (countVal) => {
  // countVal === count.value
})

Do you think it would be fine if we document the getter function way for both first, and then explicitly document directly passing a ref as a shortcut?

Yes, I think so!

@LinusBorg
Copy link
Member

@thedaemon

This particular API won't have much of an influence on how we write render functions, no.

But we'll have a separate RFC about render functions and the virtual dom soon.

About the ref <-> $ref thing: i raised the same concern in internal discussions. We will certainly have to rename one or the other i think. But that's secondary i think.

@dsonet
Copy link

dsonet commented Mar 26, 2019

I think since the value actually is just an exception of state, so doesn't have to introduce this value ref API, wrap the primitive value by user when really needed(since mostly we just use object anyway in a real world). Then we only need to know reactive object, make things much easier.
Overall, thanks for the great work.

@CyberAP
Copy link
Contributor

CyberAP commented Mar 26, 2019

Do state and value really must remain as two separate functions? I can imagine getting confused when to use which.
Maybe it could work like this?

const obj = state({ count: 1 });
computed(() => obj.count + 1);

const count = state(1);
computed(() => count.value + 1);

Also they don't declare action in their name. Maybe setState or useState would be better (but I understand that they might get confused with appropriate React functions).

@CyberAP
Copy link
Contributor

CyberAP commented Mar 26, 2019

What do you think about pointers having custom valueOf method, so it's possible just to use the return of the value function?

function value(initialValue) {
  return {
    value: initialValue,
    valueOf() {
      return this.value;
    },
  }
}

const count = value(1);
computed(() => count + 1);

That way it could interoperate with the state function.
But that won't work for setting the value unfortunately.

@Aferz
Copy link

Aferz commented Mar 26, 2019

I'm really excited about this new API. I've already tried my best attempting to implement it un user land, but It was a bit hacky (vuejs/vue#9509).

I hope this gets approved/implemented/merged as soon as possible.

Thank you for this, vue team!!

@thecrypticace
Copy link

thecrypticace commented Mar 26, 2019

In general, I love the overall API, it is very well thought out and designed.

I do have some thoughts and questions, however:

  1. I would merge value and state such that state, when given a primitive, returns a pointer. This removes the need for value entirely.

  2. Use the valueOf method on pointers to provide the primitive value (awesome idea @CyberAP).

  3. Would it be possible to return a pointer from data() as long as it returns an object? I'm thinking of being able to define the components state entirely based on the props passed to it. Kind of like how functional components are entirely dependent on props (well that and injections and what not).

I'm guessing that this might already be possible in the proposed API by using computed + object spread (… is it?) so this could be a nice shorthand.

import { computed } from 'vue'

export default {
  data() {
    return computed(() => {
      switch (this.$props.state) {
      case 'pending':
        return {loading: true}
      case 'resolved':
        return {data: "something"}
      case 'rejected':
        return {error: "oh no"}
      }
    })
  }
}
  1. I have, on occasion, wanted explicit, immtable state updates in components. Kinda like a simplified Vuex on a component level. I remember some past discussion, post, slide, or something which mentioned some way to temporarily toggle reactivity in a Vuex action so that updates would be operating on a temporarily mutable copy of Vuex state (which is otherwise immutable). I believe this was an idea for a future direction to get rid of the need to define mutations? This could probably also be compared to React's setState variant that takes a function. Would it be possible to disable automatic reactivity on a component and/or enforce that all state updates must be done through some mechanism making them explicit?

I'm thinking of something like below (The API names below probably aren't the greatest — just for demonstration purposes):

import { immutable, update } from 'vue'

export default {
  data() {
    return immutable({
      state: null,
    })
  },

  methods: {
    loadData() {
      // In `update` this components data is temporarily
      // unlocked so we can mutate it freely
      update(() => {
        this.state = "foo bar"
      })
    }
  },
}

With this, any updates to state returned by immutable must be unlocked first. This API would ensure that Vue is still observing changes (so not like Object.freeze where Vue doesn't observe anything in the tree) and that the data itself remains effectively immutable. So you can't just pass around the object and mutate it. However, being that it is still reactive, reading data from it might return a new value if it has been updated / replaced.

Secondary idea: The update function wouldn't be global. It'd be per immutable instance so only the thing that created the object has access to the mechanism to unlock its mutability.

This is just one approach. I'm 100% open to other ideas in this area.

@backbone87
Copy link

Isnt this like a poor mans mobx?

@LinusBorg
Copy link
Member

LinusBorg commented Mar 27, 2019

Ignoring your choice of words, the answer is: yes, in a way.

MobX provides the same kind of reactivity that Vue did and does, plus some abstractations over it that Vue doesn't have in core.

Since we are now exposing our reactivity system as a standalone library, the basic functionality is pretty similar.

@CyberAP
Copy link
Contributor

CyberAP commented Mar 27, 2019

I like the way React hooks solved the issue with primitives being passed by value, instead of by reference. You get a getter and a setter for these values and do not have this issue anymore. Maybe the same could be applied to Vue as well.

const [primitive, setPrimitive] = value(0); // You can't do primitive = 1;
watch(primitive, val => console.log(`Value is: ${val}`));
setPrimitive(1); // Value is: 1

For those who don't like this syntax expose internals, where it's possible to change value by accessing value property.

const primitive = value(0);
watch(primitive, val => console.log(`Value is: ${val}`));
primitive.value = 1; // Value is: 1

I believe internal implementation could look like this:

function value(initialValue) {
  let localValue = initialValue;
  const getter = () => localValue;
  const setter = (newValue) => localValue = newValue;
  const props = [{ valueOf: getter }, setter];
  props.valueOf = getter;
  Object.defineProperty(props, 'value', {
    get: getter,
    set: setter,
  })
  return props;
}

This would also work for the standard way in Vue 2.x to manually trigger reactivity with Vue.set():

import { setValue, value } from 'vue';
const primitive = value(0);
setValue(primitive, 1); // setValue could call a `value` setter method

@c01nd01r
Copy link

About Computed

Maybe, computed pointers/states should be "depends" on state, like computed(...states, getterFn)?

For example:

const { age } = state({ age: 18 })
const name = value('John');

const userInfo = computed(name, age, (name, age) => `${name} is ${age} years old `);

Pros:

  • Getter function is pure.
  • Encapsulation value field of pointer.
  • Similar to watch.

Cons:

  • Computed setter? No ideas :\ Is this feature is necessary?
  • Anything else?

@laander
Copy link

laander commented Mar 27, 2019

The most common use case for mixins I currently see, is to define a set of helper data/computed/methods/life-cycle actions that can be reused across components for common behaviour. For instance, a simple pagination helper might look something like this:

// Vue 2.x mixin
export default {
  data() {
    return {
      currentPage: 1,
      lastPage: null
    }
  },
  computed: {
    nextPage() {
      if (this.currentPage === this.lastPage) return null
      return this.currentPage + 1
    }
  }
}

With the new reactivity API, am I correct in assuming that the same could be achieved with something like the following:

// Vue 3.x with reactivity helpers
import { value, computed } from '@vue/observer'

const paginationHelper = () => {
  const currentPage = value(1)
  const lastPage = value(null)
  const nextPage = computed(() => {
    if (currentPage.value === lastPage.value) return null
    return currentPage.value + 1
  })
  return { currentPage, lastPage, nextPage }
}

export default {
  data() {
    return {
      ...paginationHelper(),
      // local data here
    }
  }
}

In a real-life scenario, the paginationHelper would obviously be extracted into its own module and exported. What I'm digging at is that the RFC could benefit with some examples of how this works in tandem with components, especially as a means to replace mixins.

EDIT: Realized that I forget to change the helper props when rewriting it. Updated now.

@LinusBorg
Copy link
Member

A couple of replys:

@thecrypticace

  1. Technically, that shouldn't be an issue. However I think the expressiveness of a seperate value(), which returns something inherently different than state() is important.

  2. We'll definitely look into the valueOf() idea.

  3. No, data will have to return an object so we can use its keys as names to inflect the properties onto the component instance.

  4. Regarding immutability, I think we even have a rough implementation of a lock mechanism in our prototype somewhere, but so far it's mainly intended to make state immutable in certain situations, i.e. forbid to mutate objects that were passed through props.

It's not part of this RFC though, and currently not externally usable I think. /cc @yyx990803 any input here?

@c01nd01r

Maybe, computed pointers/states should be "depends" on state [...]

I don't like it. We are basically taking away the convenience of automatically registering dependencies to make this function "pure", and I don't see how it's useful to have a pure function here.

@laander

With the new reactivity API, am I correct in assuming that the same could be achieved with something like the following [...]

Correct.

What I'm digging at is that the RFC could benefit with some examples of how
this works in tandem with components, especially as a means to replace mixins.

Fair enough. But this RFC is focussed on the standalone capabilites of the reactivity system. Mixins often also involve lifecycle methods, and we have another RFC for those, in #23.

In that RFC, you can find examples how that RFC and this one we are discussing right now can be used to do the same that mixins do, and more/better.

@panstromek
Copy link

This is basically mobx, I love it 😄

@chriscalo
Copy link
Contributor

Small point, but I had to re-read the writable pointer example a few times before I understood it.

const writablePointer = computed(
  // read
  () => count.value + 1,
  // write
  val => {
    count.value = val - 1
  }
)

Might this be more explicit?

const writablePlusOne = computed(
  // read
  () => count.value + 1,
  // write
  val => {
    count.value = val - 1
  }
)
console.log(count.value) // 1
console.log(writablePlusOne.value) // 2
writablePlusOne.value = 5
console.log(count.value) // 4
console.log(writablePlusOne.value) // 5

@eladFrizi
Copy link

eladFrizi commented Mar 30, 2019

We can pass to watch multiple values .

watch([plusOne, anotherReacriveRef] , [countPlusOne, anotherReacriveRefValue ] => {
  console.log(`count plus one is: ${countPlusOne} , ${anotherReacriveRefValue}`)
})
watch([currentArenaRef, currentUserRef] ,
 ([currentArena, currentUser], [prevArena, prevUser]) => {
   findMatches(currentArena,currentUser ) .... 
})

I find it very usefull.

@David-Desmaisons
Copy link

Mobx exposes API to observe an object as a whole:

import {observable, observe} from 'mobx';

const person = observable({
    firstName: "Maarten",
    lastName: "Luther"
});

const disposer = observe(person, (change) => {
    console.log(change.type, change.name, "from", change.oldValue, "to", change.object[change.name]);
});

In this API, the change callback receives the name of property that changed as well as the old and new value of the property. This is very helpful when you need to listen to all the potencial changes of an object with only one callback. It will be very practical if Vue 3 provides such an API.

@jhoffner
Copy link

jhoffner commented Apr 2, 2019

You'r obviously concerned with establishing the low level utilities first, but any plans to include a macro builder similar to how the Vue instance works now?

const person = reactive({
  data: {
    firstName: 'Joe',
    lastName: 'Smith',
    nameChanges: 0,
  },
  computed: {
    fullName() {
      return `${this.firstName} ${this.lastName}`;
    }
  },
  watch: { 
    fullName() {
      this.nameChanges++;
    }
  }
})

It would be easy enough to build on top of the provided low level APIs, but might be better to provide it up front so that a bunch of miscellaneous implementations don't start popping up all over the place.


Also I just realized that it's called state when you are working with the low level API, but data when working with a view instance. Any concerns about trying to keep them the same term?

@dodas
Copy link

dodas commented Apr 2, 2019

Seriously love that you decided to split reactivity system out, but isn't this just re-implementing MobX?
Couldn't we use MobX directly, or with some thin layer on top of it?
What is advantage of Vue having its own implementation?

Thanks for reply and your work!

@backbone87
Copy link

mobx also comes with a es6 proxy version

@panstromek
Copy link

panstromek commented Apr 2, 2019

@dodas Well, that would mean that you will have two reactivity systems in the app unnecessarily. Vue will use its reactivity system anyway, this is only a way to expose it as public API. Having this "in-house" is a good way to ensure compatibility, I doubt Vue would ever use 3rd party lib for such a core feature as reactivity system.

You can already use MobX with mobx-vue, though ;) But I don't really know what's the cost of having two systems tracking dependencies.. MobX is great, but I guess I'll wait for this API to land, just to be sure I don't get into some trouble ;)

@HendrikJan
Copy link

HendrikJan commented Apr 2, 2019

Could the watchers probably be written without having to specify what you are watching?
Like so:

watch(() => {
  console.log(`obj.a is: ${obj.a}`)
})

watch(() => {
  console.log(`count is: ${count}`)
})

watch(() => {
  console.log(`count plus one is: ${plusOne}`)
})

The passed function would then recalculate on changes of any used reactive value.
You could also easily watch multiple reactive values:

watch(() => {
  console.log(`count is: ${count}, and count plus one is ${plusOne}`)
})

@LinusBorg
Copy link
Member

@David-Desmaisons That's an interesting suggestion!

However if we implement it, maybe we should consider making it a dedicated API, since I'm not sure that we back backport that functionality to the compatibility build which reimplements the old getter&setter based Reactivity for IE compatibility.

But I'm liking the feature, definitely.

@LinusBorg
Copy link
Member

@jhoffner I personally don't see a reason for providing such a mechanism in our own core, since it would be trivial to implement in userland while it doesn't really provide any objectively tangible advantage over doing this:

const person = {
  firstName: value('Joe'),
  lastName: value('Smith'),
  nameChanges: value(0),
  fullName: computed(() =>`${person.firstName} ${person.lastName}`)
}

watch(person.fullName, () => person.nameChanges.value++)

Also I just realized that it's called state when you are working with the low level API, but data when working with a view instance. Any concerns about trying to keep them the same term

We are aware of this and are internally debating what to do with this. Naming things one of the two hard problems in CS, you can imagine it's not easy ^^

@LinusBorg
Copy link
Member

@dodas Basically what @panstromek said...

@LinusBorg
Copy link
Member

LinusBorg commented Apr 2, 2019

@HendrikJan Not really, for multiple reasons.

  1. This wouldn't allow for lazy watchers as the side effect has to be run immediately to collect the dependencies
  2. Since wachers are meant to have side effects, you could easily create infinite loops:
const lock = value(false)
const counter = value(1)
watch(() => {
  if (lock.value && counter.value !== 0) {
    counter.value++
  }
})

Thee above would result in us watching the lock and counter pointers for changes. Since we also mutate the counter as a side effect, this would lead to an endless update loop.

  1. Collecting dependencies in async operations doesn't work, as dependency collection has to be done synchronously:
watch(() =>{
  if (somePointer) {
    await someOp(() => {
      if (someOtherPointer) { ... }
    }
  }
})

We can't collect someOtherPointer as a dependency, so changes to it won't trigger the watcher.

@HendrikJan
Copy link

@LinusBorg

Infinite loops might be easy to detect and give warnings about, but point 1 and 3 are good arguments.

@LinusBorg
Copy link
Member

LinusBorg commented Apr 2, 2019

Infinite loops might be easy to detect and give warnings about

Sure, but what good would the warning be... The consequence is that you can't modify any reactive dependency from a watcher that you also need to access in that watcher.

And trying to technically prevent such an infinite loop in watch's implemantation won't work realibly since there would be so many edge cases to consider.

@agronick
Copy link

agronick commented Apr 2, 2019

Looks good. I'd like to have the same API for objects and primitives. The ability to provide an array to watch on is a must have.

@David-Desmaisons
Copy link

David-Desmaisons commented Apr 2, 2019

@LinusBorg

However if we implement it, maybe we should consider making it a dedicated API

A dedicated API would be fine indeed. I have a specific scenario for Neutronium integration where I need to listen to all properties of an object and to know which property has been changed. With the current implementation, I need to register a callback for each properties. Having a single callback as in mobx would result in a potentially substantial memory optimization.

@agronick
Copy link

agronick commented Apr 8, 2019

@David-Desmaisons I agree. I'd like to have these internals exposed so I'm not adding duplicate code when I need to do things outside the framework. Things like event handlers and animations work well enough when they can be set up with HTML but when they need to be done in JS I end up adding a bunch of redundant helper functions. This is similar.

@yyx990803
Copy link
Member Author

@LinusBorg @HendrikJan note in the RFC this is actually supported:

watch(() => {
  console.log(obj.a)
})

There isn't anything inherently wrong with this, since mutating a watcher's own dependency in the callback would result in infinite loops too:

watch(somePointer, () => {
  somePointer.value++
})

@yyx990803
Copy link
Member Author

@eladFrizi note watching multiple values can be easily done in userland:

watch(
  () => [currentArenaRef.value, currentUserRef.value],
  ([currentArena, currentUser], [prevArena, prevUser]) => {
    findMatches(currentArena,currentUser ) ...
  }
})

@nerdcave
Copy link

What's the advantage of these patterns over simply creating additional Vue instances to encapsulate what you want? Performance? Or am I missing other advantages? For example:

<div id="app">
  <p>app: {{ x }} {{ y }}</p>
  <child></child>
</div>
const mouseTracker = new Vue({
  data: { x: 0, y: 0 },
  created() {
    window.addEventListener('mousemove', this.update)
  },
  destroyed() {
    window.removeEventListener('mousemove', this.update)
  },
  methods: {
    update(e) {
      this.x = e.pageX
      this.y = e.pageY
    }
  }
})


new Vue({
  el: '#app',
  components: {
    child: {
      template: `<p>child: {{ x }}, {{ y }}</p>`,
      computed: {
        x: () => mouseTracker.x,
        y: () => mouseTracker.y,
      }
    },
  },
  computed: {
    x: () => mouseTracker.x,
    y: () => mouseTracker.y,
  }
})

https://jsfiddle.net/cfy8ensb/

@LinusBorg
Copy link
Member

LinusBorg commented May 11, 2019

A direct advantage would be a smaller memory footprint as you don't have to create whole Vue instances when you only need the reactivity functionality itself. Plus, you could use that anywhere by only importing the @vue/observer package that will encapsulate Vue's reactivity system in Vue 3.0

Another advantage comes to light when combining it with #23 (Dynamic Lifecycle injection). It allows to colocate behaviours that have to be triggered at various points in a component's lifecycle in a way that is much more fluent and can be effortlessly be combined with other behaviours that are written in that way. You can find a few examples of that in that linked RFC.

Together they form a pattern close to what React's new Hooks API provides. It can replace Mixins and has none of their drawbacks.

@alinnert
Copy link

@LinusBorg one question about the package itself. In March you wrote:

Since we are now exposing our reactivity system as a standalone library, (...).

Does that mean I could install and use the reactivity system in a project without depending on Vue itself? Like npm install @vue/observer?

@LinusBorg
Copy link
Member

Yes.

@yyx990803
Copy link
Member Author

Closing as part of #42.

@yyx990803 yyx990803 closed this Jun 9, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.x This RFC only targets 3.0 and above core
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet