Reactivity in Depth

When you modify reactive JavaScript object, the view updates.


Reactivity in Depth

By default, JavaScript cannot automatically update values that are connected to other values. If we have one value (A1) that is related to another value (A2) and want to connect them so that when you modify A2 value, A1 value will also change, we will need some function that will trigger and update A1.

Reactivity in Vue works diferently.

There are two ways of intercepting property access in JavaScript: getter / setters and Proxies.

Vue 2 used getter / setters exclusively due to browser support limitations.

In Vue 3, Proxies are used for reactive objects and getter / setters are used for refs.

Limitations of reactive objects:

  • When you assign or destructure a reactive object's property to a local variable, accessing or assigning to that variable is non-reactive because it no longer triggers the get / set proxy traps on the source object.

  • The returned proxy from reactive(), although behaving just like the original, has a different identity if we compare it to the original using the === operator.

When you get value from ref function you will trigger track() function, and when you set value in ref you will trigger trigger() function;

Inside track() it will first check if there's currently running effect and if does it will lookup for subscriber effect for the propery that is tracked. If no subscribing effects was found, it will be created one.

A running effect is a function currently executing and tracking dependencies, while a subscribed effect is a function that has been registered to be notified when a specifide reactive property changes.

Inside trigger() it will lookup for subscriber efects and it will invoke them.

In summary, when you use a ref in a template, and change the ref's value later, Vue automatically detects the change and updates the DOM accordingly. This is made possible with a dependency-tracking based reactivity system. When a component is rendered for the first time, Vue tracks every ref that was used during the render. Later on, when a ref is mutated, it will trigger a re-render for components that are tracking it.

Runtime vs Compile-time Reactivity

Vue's reactivity system is primarily runtime-based: the tracking and triggering are all performed while the code is running directly in the browser. The pros of runtime reactivity are that it can work without a build step, and there are fewer edge cases. On the other hand, this makes it constrained by the syntax limitations of JavaScript, leading to the need of value containers like Vue refs.

Some frameworks, such as Svelte, choose to overcome such limitations by implementing reactivity during compilation. It analyzes and transforms the code in order to simulate reactivity.

Reactivity Debugging

Debug hooks only work in development mode.

We can debug what dependencies are used during a component's render and which dependency is triggering an update using the onRenderTracked and onRenderTriggered lifecycle hooks. It is recommended to place a debugger statement in the callbacks to interactively inspect the dependency:

<script setup>
import { onRenderTracked, onRenderTriggered } from "vue";

onRenderTracked((event) => {
  debugger;
});

onRenderTriggered((event) => {
  debugger;
});
</script>

We can debug computed properties by passing computed() a second options object with onTrack and onTrigger callbacks:

onTrack will be called when a reactive property or ref is tracked as a dependency. onTrigger will be called when the watcher callback is triggered by the mutation of a dependency. Both callbacks will receive debugger events in the same format as component debug hooks:

const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // triggered when count.value is tracked as a dependency
    debugger;
  },
  onTrigger(e) {
    // triggered when count.value is mutated
    debugger;
  },
});

// access plusOne, should trigger onTrack
console.log(plusOne.value);

// mutate count.value, should trigger onTrigger
count.value++;
watch(source, callback, {
  onTrack(e) {
    debugger;
  },
  onTrigger(e) {
    debugger;
  },
});

watchEffect(callback, {
  onTrack(e) {
    debugger;
  },
  onTrigger(e) {
    debugger;
  },
});

Integration with External State Systems

Vue's reactivity system works by deeply converting plain JavaScript objects into reactive proxies.

The general idea of integrating Vue's reactivity system with an external state management solution is to hold the external state in a shallowRef. A shallow ref is only reactive when its .value property is accessed - the inner value is left intact. When the external state changes, replace the ref value to trigger updates.

Immutable Data

If you are implementing an undo / redo feature, you likely want to take a snapshot of the application's state on every user edit. However, Vue's mutable reactivity system isn't best suited for this if the state tree is large, because serializing the entire state object on every update can be expensive in terms of both CPU and memory costs.

Immutable data structures solve this by never mutating the state objects - instead, it creates new objects that share the same, unchanged parts with old ones. There are different ways of using immutable data in JavaScript, but we recommend using Immer with Vue because it allows you to use immutable data while keeping the more ergonomic, mutable syntax.

import produce from "immer";
import { shallowRef } from "vue";

export function useImmer(baseState) {
  const state = shallowRef(baseState);
  const update = (updater) => {
    state.value = produce(state.value, updater);
  };

  return [state, update];
}

State Machines

State Machine is a model for describing all the possible states an application can be in, and all the possible ways it can transition from one state to another. While it may be overkill for simple components, it can help make complex state flows more robust and manageable.

import { createMachine, interpret } from "xstate";
import { shallowRef } from "vue";

export function useMachine(options) {
  const machine = createMachine(options);
  const state = shallowRef(machine.initialState);
  const service = interpret(machine)
    .onTransition((newState) => (state.value = newState))
    .start();
  const send = (event) => service.send(event);

  return [state, send];
}

Connection to Signals

Fundamentally, signals are the same kind of reactivity primitive as Vue refs. It's a value container that provides dependency tracking on access, and side-effect triggering on mutation.

Although not a necessary trait for something to qualify as signals, today the concept is often discussed alongside the rendering model where updates are performed through fine-grained subscriptions. Due to the use of Virtual DOM, Vue currently relies on compilers to achieve similar optimizations. However, we are also exploring a new Solid-inspired compilation strategy (Vapor Mode) that does not rely on Virtual DOM and takes more advantage of Vue's built-in reactivity system.

In Solid you can passed down value without the setter. This ensures that the state can never be mutated unless the setter is also explicitly exposed. Whether this safety guarantee justifies the more verbose syntax could be subject to the requirement of the project and personal taste.

import { shallowRef, triggerRef } from "vue";

export function createSignal(value, options) {
  const r = shallowRef(value);
  const get = () => r.value;
  const set = (v) => {
    r.value = typeof v === "function" ? v(r.value) : v;
    if (options?.equals === false) triggerRef(r);
  };
  return [get, set];
}

const [count, setCount] = createSignal(0);

count(); // access the value
setCount(1); // update the value

Angular signals

import { shallowRef, triggerRef } from "vue";

export function signal(initialValue) {
  const r = shallowRef(initialValue);
  const s = () => r.value;
  s.set = (value) => {
    r.value = value;
  };
  s.update = (updater) => {
    r.value = updater(r.value);
  };
  s.mutate = (mutator) => {
    mutator(r.value);
    triggerRef(r);
  };
  return s;
}

const count = signal(0);

count(); // access the value
count.set(1); // set new value
count.update((v) => v + 1); // update based on previous value

// mutate deep objects with same identity
const state = signal({ count: 0 });
state.mutate((o) => {
  o.count++;
});

Compared to Vue refs, Solid and Angular's getter-based API style provide some interesting trade-offs when used in Vue components:

  • () is slightly less verbose than .value, but updating the value is more verbose.
  • There is no ref-unwrapping: accessing values always require (). This makes value access consistent everywhere. This also means you can pass raw signals down as component props.