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 #24

Closed
yyx990803 opened this issue Mar 20, 2019 · 23 comments
Closed

Advanced Reactivity API #24

yyx990803 opened this issue Mar 20, 2019 · 23 comments

Comments

@yyx990803
Copy link
Member

yyx990803 commented Mar 20, 2019

Review note: I've split this part out of the React hooks like composition API because it is not strictly coupled to that proposal.


  • Start Date: 03-05-2019
  • Target Major Version: 2.x & 3.x
  • Reference Issues: N/A
  • Implementation PR: N/A

Summary

Provide standalone APIs for creating and observing reactive state.

Basic example

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}`)
})

Motivation

Decouple the reactivity system from component instances

Vue's reactivity system powers a few aspects of Vue:

  • Tracking dependencies used during a component's render for automatic component re-render

  • Tracking dependencies of computed properties to only re-compute values when necessary

  • Expose this.$watch API for users to perform custom side effects in response to state changes

Until 2.6, the reactivity system has largely been considered an internal implementation, and there is no dedicated API for creating / watching reactive state without doing it inside a component instance.

However, such coupling isn't technically necessary. In 3.x we've already split the reactivity system into its own package (@vue/observer) with dedicated APIs, so it makes sense to also expose these APIs to enable more advanced use cases.

With these APIs it becomes possible to encapsulate stateful logic and side effects without components involved. In addition, with proper ability to "connect" the created state back into component instances, they also unlock a powerful component logic reuse mechanism.

Detailed design

Reactive Objects

In 2.6 we introduced the observable API for creating reactive objects. We've noticed the naming causes confusion for some users who are familiar with RxJS or reactive programming where the term "observable" is commonly used to denote event streams. So here we intend to rename it to simply state:

import { state } from 'vue'

const object = state({
  count: 0
})

This works exactly like 2.6 Vue.observable. The returned object behaves just like a normal object, and when its properties are accessed in reactive computations (render functions, computed property getters and watcher getters), they are tracked as dependencies. Mutation to these properties will cause corresponding computations to re-run.

Value Refs

The state API cannot be used for primitive values because:

  • Vue tracks dependencies by intercepting property accesses. Usage of primitive values in reactive computations cannot be tracked.

  • JavaScript values are not passed by reference. Passing a value directly means the receiving function will not be able to read the latest value when the original is mutated.

The simple solution is wrapping the value in an object wrapper that can be passed around by reference. This is exactly what the value API does:

import { value } from 'vue'

const countRef = value(0)

The value API creates a wrapper object for a value, called a ref. A ref is a reactive object with a single property: .value. The property points to the actual value being held and is writable:

// read the value
console.log(countRef.value) // 0

// mutate the value
countRef.value++

Refs are primarily used for holding primitive values, but it can also hold any other values including deeply nested objects and arrays. Non-primitive values held inside a ref behave like normal reactive objects created via state.

Computed Refs

In addition to plain value refs, we can also create computed refs:

import { value, computed } from 'vue'

const count = value(0)
const countPlusOne = computed(() => count.value + 1)

console.log(countPlusOne.value) // 1
count.value++
console.log(countPlusOne.value) // 2

Computed refs are readonly by default - assigning to its value property will result in an error.

Computed refs can be made writable by passing a write callback as the 2nd argument:

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

Computed refs behaves like computed properties in a component: it tracks its dependencies and only re-evaluates when dependencies have changed.

Watchers

All .value access are reactive, and can be tracked with the standalone watch API.

NOTE: unlike 2.x, the watch API is immediate by default.

watch can be called with a single function. The function will be called immediately, and will be called again whenever dependencies change:

import { value, watch } from 'vue'

const count = value(0)

// watch and re-run the effect
watch(() => {
  console.log('count is: ', count.value)
})
// -> count is: 0

count.value++
// -> count is: 1

Watch with a Getter

When using a single function, any reactive properties accessed during its execution are tracked as dependencies. The computation and the side effect are performed together. To separate the two, we can pass two functions instead:

watch(
  // 1st argument (the "computation", or getter) should return a value
  () => count.value + 1,
  // 2nd argument (the "effect", or callback) only fires when value returned
  // from the getter changes
  value => {
    console.log('count + 1 is: ', value)
  }
)
// -> count + 1 is: 1

count.value++
// -> count + 1 is: 2

Watching Refs

The 1st argument can also be a ref:

// double is a computed ref
const double = computed(() => count.value * 2)

// watch a ref directly
watch(double, value => {
  console.log('double the count is: ', value)
})
// -> double the count is: 0

count.value++
// -> double the count is: 2

Stopping a Watcher

A watch call returns a stop handle:

const stop = watch(...)

// stop watching
stop()

If watch is called inside lifecycle hooks or data() of a component instance, it will automatically be stopped when the associated component instance is unmounted:

export default {
  created() {
    // stopped automatically when the component unmounts
    watch(() => this.id, id => {
      // ...
    })
  }
}

Effect Cleanup

The effect callback can also return a cleanup function which gets called every time when:

  • the watcher is about to re-run
  • the watcher is stopped
watch(idRef, id => {
  const token = performAsyncOperation(id)

  return () => {
    // id has changed or watcher is stopped.
    // invalidate previously pending async operation
    token.cancel()
  }
})

Non-Immediate Watchers

To make watchers non-immediate like 2.x, pass additional options via the 3rd argument:

watch(
  () => count.value + 1,
  () => {
    console.log(`count changed`)
  },
  { immediate: false }
)

Exposing Refs to Components

While this proposal is focused on working with reactive state outside of components, such state should also be usable inside components as well.

Refs can be returned in a component's data() function:

import { value } from 'vue'

export default {
  data() {
    return {
      count: value(0)
    }
  }
}

When a ref is returned as a root-level property in data(), it is bound to the component instance as a direct property. This means there's no need to access the value via .value - the value can be accessed and mutated directly as this.count, and directly as count inside templates:

<div @click="count++">
  {{ count }}
</div>

Beyond the API

The APIs proposed here are just low-level building blocks. Technically, they provide everything we need for global state management, so Vuex can be rewritten as a very thin layer on top of these APIs. In addition, when combined with the ability to programmatically hook into the component lifecycle, we can offer a logic reuse mechanism with capabilities similar to React hooks.

Drawbacks

  • To pass state around while keeping them "trackable" and "reactive", values must be passed around in the form of ref containers. This is a new concept and can be a bit more difficult to learn than the base API. However, these APIs are intended for advanced use cases so the learning cost should be acceptable.

Alternatives

N/A

Adoption strategy

This is mostly new APIs that expose existing internal capabilities. Users familiar with Vue's existing reactivity system should be able to grasp the concept fairly quickly. It should have a dedicated chapter in the official guide, and we also need to revise the Reactivity in Depth section of the current docs.

Unresolved questions

  • watch API overlaps with existing this.$watch API and watch component option. In fact, the standalone watch API provides a superset of existing APIs. This makes the existence of all three redundant and inconsistent.

    Should we deprecate this.$watch and watch component option?

    Sidenote: removing this.$watch and the watch option also makes the entire watch API completely tree-shakable.

  • We probably need to also expose a isRef method to check whether an object is a value/computed ref.

@LinusBorg
Copy link
Member

Love it! <3

Should we deprecate this.$watch and watch component option?
Sidenote: removing this.$watch and the watch option also makes the entire watch API completely tree-shakable.

deprecate or remove ?

We should deprecate them, but not remove them.

While removing them would make them tree-shakable, we are forcing everyone who is currently using these old APIs to switch to the new one (so no advantage of tree-shaking here - they are already using the feature).

Forcing people to switch to the new API partially collides with this statement:

This is a new concept and can be a bit more difficult to learn than the base API. However, these APIs are intended for advanced use cases so the learning cost should be acceptable.

And it will make migrating to a 3.0 unnecessarily harder.

However, I would be willing to consider a removal if we can make the old API exist in the compatibility build, but even then I'm a bit torn about forcing people to forget about the old API and learn the new API when they start their next project that doesn't require the compatibility build.

By deprecating these APIs, we leave people the choice to use this if they feel more comfortable with it, accepting the risk of having to refactor for Vue 4.0.

@yyx990803 yyx990803 changed the title Advanced Reactivity API RFC Draft Advanced Reactivity API Mar 20, 2019
@LinusBorg
Copy link
Member

LinusBorg commented Mar 20, 2019

Naming of "refs"

The value API creates a wrapper object for a value, called a ref. A ref is a reactive object with a single property: .value. The property points to the actual value being held and is writable:

Can we call this "reactive ref" or something? or drop in a note about not to confuse them with template refs?

state vs value and mutability/reactivity

The usage of value with primitive values, i.e. strings, is clear to me. However, what about the following:

const ref = value({
  foo: 'bar',
})

ref.value.foo = 'baz'

Would that be reactive? Would that be allowed or recommended, or discouraged/disabled?

What about this?

const ref = value(state({
  foo: 'bar',
}))

ref.value.foo = 'baz'

Now we have an explicitly defined reactive object. Does it even make sense to do it? It seems possible, but ... do we gain anything from this compared to

const myState = state({
  foo: 'bar',
})

myState.value.foo = 'baz'

In other words: Is there a situation where I would use value(/* { some object }*/) over state(/* { some object }*/), and if so, why?

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 21, 2019

@LinusBorg

  • Reactive vs template refs: yeah this is a potential source of confusion. I can't think of a better term than "ref" though because it is literally a reference to a value. Any other naming suggestions?

    It is interesting because in React they have unified DOM refs vs. value refs (by making DOM refs also object containers pointing to the DOM node)

  • state is like 2.6 Vue.observable. It returns plain objects, not refs.

  • Any non-primitive value passed to value is implicitly converted via state. So:

     const objRef = value({ a: 123 })
     const obj = objRef.value
    
     // is equivalent to
     const obj = state({ a: 123 })

    Think of refs as one extra layer of wrapper around reactive values. Once you take away that wrapper it is the same as a typical Vue reactive object.

@HcySunYang
Copy link
Member

HcySunYang commented Mar 22, 2019

If I don't misunderstand, the value api is used to solve the problem that the state api does not support the primitive value, so why not limit value api to only accept primitive value as a parameter? this prevents users from writing weird code:

const objRef = value({ value: 123 })
objRef.value.value  // very strange

As an alternative, if the user can only use the state api to handle non-primitive values:

const obj = state({ value: 123 })
obj.value  // clear

const countRef = value(0)
countRef.value  // clear

const objRef = value({ foo: 1 })  // error: use state() instead of value() for non-primitive values

@HcySunYang
Copy link
Member

The implementation of value api is similar to: value => state({ value })? if so, is it really necessary?

@LinusBorg
Copy link
Member

LinusBorg commented Mar 22, 2019

so why not limit value api to only accept primitive value as a parameter?

Also thougth about this, but this would make it harder for people to write code like this:

const options = state('default')

// something happens:

options.value = {
  // options object for more specific setup
}

// whereas this would work:
options.value = 500 // that's a prmitive

If we limit refs to aceppt only primitive values, people would have to use (and possibly expose on the component) two refs instead of one.

The implementation of value api is similar to: value => state({ value })? if so, is it really necessary?

For one, the object returned by state() will at least have to have some sort of marker to allow us to check if an object is in fact a ref.

We need to be able to check for this so we can expose refs as component properties directly:

beforeCreate() {
  return {
    name: state('Linus')
  }.
methods: {
  changeName(name) {
    this.name = name // we can internally translat this to `name.value = name` if we kow that `name` is a ref
  }
}

Also, we could seal() the ref object so you can't add or remove properties, and value is and will be the only property exposed etc.

@HcySunYang
Copy link
Member

@LinusBorg
Hi, I mean only restrict value() api to accept the primitive value as a parameter, state() api still accepts non-primitive values.

The following are allowed:

const obj = state({ value: 123 })
obj.value  // clear

const countRef = value(0)
countRef.value  // clear

But not allowed:

const countRef = value({ foo: 1 })  // error: use state() instead of value() to handle non-primitive values
const obj = state('primitiveValue')  // error: you should use value() to handle primitive values

@LinusBorg
Copy link
Member

but would you allow this?

const countRef = value(0)
countRef.value  // clear

countRef.value = [1,2]
  • If yes, then why forbid the other way around?
  • If no, then my previous comment's explanation is valid.

@HcySunYang
Copy link
Member

I got your point, this is really a problem.

@posva
Copy link
Member

posva commented Mar 23, 2019

If value(3) is equivalent for state({ value: 3 }), why not make state behave like value with any non-object value and remove value altogether?

What has to be returned exactly by the watcher? Is it like a computed property created on the fly and observing the resulting value? I'm asking because it already causes some misconceptions with current $watch with a function as first parameter. For example, having to stringify an object to see if it changed. I think this is okay because it brings more control over when to fire the watcher but we have to be explicit about what we do with the returned value.

I like the effect cleanup! How do you know if the watcher is about to be ran or to be stopped instead

How often are watchers called? Are calls still delayed every tick so that changing the same value twice will only trigger the watcher once? Is this something we want to support trought an option?

Why would someone use value or state inside data? I don't know if we should allow that nor the magic behind being able to directly access the value

@yyx990803
Copy link
Member Author

@posva @HcySunYang

value(3) is different from state({ value: 3 }). A ref is differentiable from a normal reactive object in the following ways:

  • a ref, when returned from data(), is bound as a direct property so it doesn't have to be accessed as xxx.value in templates.

  • a ref can be watched directly by watch().

  • a ref can only have one single property (.value).

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 23, 2019

@posva

What has to be returned exactly by the watcher? Is it like a computed property created on the fly and observing the resulting value?

A watcher getter can return anything. The getter fires whenever any dependency changes, and the callback only fires when the return value changes. If the user wants to see if the object has been mutated, they should use a deep watcher (just like with $watch).

How do you know if the watcher is about to be ran or to be stopped instead

I can't really think of case where this matters. Is it really necessary?

How often are watchers called? Are calls still delayed every tick so that changing the same value twice will only trigger the watcher once? Is this something we want to support trought an option?

This is a good point. Currently, $watch fires after nextTick, but there's a hidden sync option. I think something simpler would be firing sync by default - if the user wants to wait until after DOM is updated, just use nextTick inside the callback.

Why would someone use value or state inside data?

They should not use them directly inside data(). The advanced API is only useful when you want to extract standalone logic into a function like React custom hooks.

@yyx990803 yyx990803 reopened this Mar 23, 2019
@posva
Copy link
Member

posva commented Mar 23, 2019

It's clearer now but there are still some things not clear for me regarding the usage of state and value.

About the watcher firing synchrously by default, putting a nexttick inside the callback would still run the function twice though, wouldn't it?

@yyx990803
Copy link
Member Author

About the watcher firing synchrously by default, putting a nexttick inside the callback would still run the function twice though, wouldn't it?

Why?

@posva
Copy link
Member

posva commented Mar 23, 2019

If the watcher fires synchrously, we may queue up multiple callbacks with nexttick, right? We are delaying the execution but still executing multiple times

@yyx990803
Copy link
Member Author

@posva it doesn't matter. Implicit nextTick has to fire a internal sync callback anyway.

@posva
Copy link
Member

posva commented Mar 25, 2019

How would you write that?

If we do

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

and then obj.a = 4; obj.a = 3, wouldn't that console.log 4 and 3?

@yyx990803
Copy link
Member Author

@posva ah that's right. I guess we do need an option for it then.

@yyx990803
Copy link
Member Author

Published: vuejs/rfcs#22

@posva
Copy link
Member

posva commented Mar 26, 2019

One thing we left behind is 2.x support. If @vue/observer uses proxies, it won't be compatible with Vue 2, would it?

@LinusBorg
Copy link
Member

LinusBorg commented Mar 26, 2019

Don't think so. state and value could just as well return objects that have Vue2 style getters and setters. People would just have to import a different build.

@posva
Copy link
Member

posva commented Mar 26, 2019

would still dependency tracking work?

@LinusBorg
Copy link
Member

I don't see why not. We do it with Vue.observable() today as well, basically.

@github-actions github-actions bot locked and limited conversation to collaborators Nov 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants