Skip to content

Vue 3 Composition API and Reactivity

expert20 min read

Vue's Middle Path

Vue occupies a unique position in the reactivity landscape. It has a virtual DOM like React, but a signal-like reactivity system like Solid. It re-renders components, but only the components whose reactive dependencies actually changed. It's the framework that asked: what if we took the best parts of both models?

The result is a reactivity system built on ES2015 Proxies that automatically tracks nested object mutations, paired with a compiler that aggressively optimizes the virtual DOM diffing that React does on every render.

<script setup>
import { ref, computed, watchEffect } from 'vue';

const count = ref(0);
const doubled = computed(() => count.value * 2);

watchEffect(() => {
  console.log(`Count: ${count.value}, Doubled: ${doubled.value}`);
});
</script>

<template>
  <button @click="count++">{{ count }} (doubled: {{ doubled }})</button>
</template>

The Mental Model

Mental Model

Vue's reactivity is like a smart home system. Each ref and reactive object is a room with sensors. When you walk into a room (read a property), the sensor detects you and registers that you're "interested" in that room. When something changes in the room (property mutation), the system notifies everyone who previously visited. The smart home controller (Vue's scheduler) then efficiently updates only the displays (components) connected to those rooms, rather than refreshing every screen in the house.

ref() vs reactive(): The Two APIs

Vue gives you two ways to create reactive state. This confuses newcomers, but they serve distinct purposes.

ref() -- Single Value Reactivity

const count = ref(0);

count.value;      // Read: 0 (tracked if inside a reactive context)
count.value = 5;  // Write: triggers subscribers
count.value++;    // Mutation: triggers subscribers

A ref wraps any value (primitive or object) in a reactive container. You access it through .value. In templates, Vue automatically unwraps refs, so you write {{ count }} not {{ count.value }}.

Under the hood, ref() for primitives uses a getter/setter pair (like signals). For objects, it wraps the inner value with reactive().

reactive() -- Deep Object Reactivity

const state = reactive({
  user: {
    name: 'Alice',
    address: {
      city: 'San Francisco'
    }
  },
  items: [1, 2, 3]
});

state.user.name = 'Bob';              // Tracked and triggers updates
state.user.address.city = 'New York'; // Deep nested mutation -- also tracked!
state.items.push(4);                  // Array mutation -- also tracked!

reactive() wraps the object in an ES2015 Proxy that intercepts every property access and mutation at any depth. This is the "it just works" API -- mutate any property, at any nesting level, and Vue knows.

Quiz
What is the key difference between ref() and reactive() in Vue 3?

The ref() vs reactive() Decision

Key Rules
  1. 1Prefer ref() for most cases -- it works with primitives and objects, and its .value makes reactivity explicit in JavaScript code
  2. 2Use reactive() when you have a complex nested object where .value access on every property would be verbose
  3. 3Never destructure reactive() objects -- it breaks reactivity because you extract plain values from the Proxy
  4. 4ref() values auto-unwrap in templates, computed, and watchEffect -- no .value needed in those contexts
// Destructuring breaks reactive()
const state = reactive({ count: 0, name: 'Alice' });
const { count, name } = state;  // count and name are plain values, NOT reactive!

// This is why ref() is safer
const count = ref(0);
const name = ref('Alice');
// Can't accidentally destructure away reactivity

Proxy-Based Tracking: How Vue Knows

Vue's reactivity works through ES2015 Proxy get and set traps:

function reactive(target) {
  return new Proxy(target, {
    get(obj, key, receiver) {
      track(obj, key);  // Register this property as a dependency
      const result = Reflect.get(obj, key, receiver);
      if (isObject(result)) {
        return reactive(result);  // Deep: wrap nested objects lazily
      }
      return result;
    },
    set(obj, key, value, receiver) {
      const oldValue = obj[key];
      const result = Reflect.set(obj, key, value, receiver);
      if (!Object.is(oldValue, value)) {
        trigger(obj, key);  // Notify subscribers of this property
      }
      return result;
    }
  });
}

The track function registers the currently running effect as a subscriber of this specific property. The trigger function notifies all subscribers of this property that it changed.

The dependency map structure looks like this:

WeakMap<object, Map<key, Set<effect>>>

targetMap = {
  state: {
    'count': [effect1, effect2],
    'name': [effect3]
  },
  state.user: {
    'name': [effect1],
    'address': [effect4]
  }
}

This is per-property granularity. Changing state.count only notifies effects that actually read state.count, not effects that read state.name.

Common Trap

Vue's Proxy reactivity has edge cases. You can't use reactive() with Map, Set, or other built-in collection types the same way as plain objects. Vue provides special handling for these through shallowReactive() or by wrapping them in ref(). Also, replacing an entire reactive object breaks references: state = reactive({ new: 'object' }) creates a new proxy, and anything tracking the old proxy won't update. Always mutate properties instead of replacing the entire object.

Quiz
In Vue 3, when you write state.user.address.city = 'NYC' on a reactive object, how does Vue track this nested mutation?

computed() and watch APIs

computed() -- Cached Derived State

const firstName = ref('Alice');
const lastName = ref('Smith');

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});

Vue's computed() works like every signal system's computed: lazy, cached, auto-tracking. It only recalculates when a dependency changes AND the value is read.

Vue also supports writable computeds:

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (val) => {
    const [first, last] = val.split(' ');
    firstName.value = first;
    lastName.value = last;
  }
});

fullName.value = 'Bob Jones';  // Sets firstName and lastName

watchEffect() -- Auto-tracking Effects

watchEffect(() => {
  console.log(`User: ${user.value.name}`);
});

watchEffect is Vue's effect primitive. It runs immediately, auto-tracks dependencies, and re-runs when any dependency changes. Cleanup is handled via the onCleanup parameter:

watchEffect((onCleanup) => {
  const controller = new AbortController();
  fetch(`/api/user/${userId.value}`, { signal: controller.signal });
  onCleanup(() => controller.abort());
});

watch() -- Explicit Dependency Watching

watch(userId, async (newId, oldId) => {
  const data = await fetchUser(newId);
  userData.value = data;
});

watch(
  () => state.user.name,
  (newName, oldName) => {
    console.log(`Name changed from ${oldName} to ${newName}`);
  }
);

watch is like watchEffect but with explicit sources. It gives you the old and new values and doesn't run immediately by default. Use it when you need to know what changed, not just that something changed.

FeaturewatchEffect()watch()
Dependency trackingAutomatic (reads inside callback)Explicit (first argument)
Runs immediatelyYesNo (unless immediate: true)
Access to old valueNoYes (second parameter)
Best forSide effects that depend on multiple reactive valuesReacting to specific value changes with old/new comparison

Vue's Compiler Optimizations

Vue uses a virtual DOM, but its compiler makes it much faster than a naive VDOM diff. The compiler analyzes templates at build time and generates optimized render functions.

Static Hoisting

<template>
  <div>
    <h1>Welcome to My App</h1>           <!-- Static: hoisted -->
    <p>This never changes</p>            <!-- Static: hoisted -->
    <span>{{ dynamicValue }}</span>       <!-- Dynamic: tracked -->
    <footer>Built with Vue</footer>      <!-- Static: hoisted -->
  </div>
</template>

The compiler identifies static nodes (no reactive bindings) and hoists them outside the render function. They're created once and reused across re-renders. Only the span with dynamicValue is part of the diff.

Patch Flags

Vue marks dynamic nodes with flags indicating what can change:

createElementVNode('span', null, dynamicValue, PatchFlags.TEXT);

During diffing, Vue checks the flag: PatchFlags.TEXT means only the text content can change, so it skips attribute, class, and style comparisons entirely. This turns O(attributes) diffing into O(1).

Block Tree

Vue groups dynamic nodes into "blocks." Instead of diffing the entire VDOM tree, Vue only diffs nodes within the same block that have patch flags. Static subtrees are skipped entirely.

The result: Vue's VDOM diffing is closer to O(dynamic nodes) than O(total nodes), approaching the performance of no-VDOM frameworks for templates with mostly static content.

Quiz
How do Vue's patch flags improve virtual DOM diffing performance?

Vue 3.5 and 3.6: The Alien Signals Era

Vue 3.5 brought a major reactivity refactor: 56% memory reduction and up to 10x faster tracking for large reactive arrays. Reactive Props Destructure was stabilized, allowing:

<script setup>
const { name, age } = defineProps(['name', 'age']);
// name and age are reactive! This was experimental before 3.5
</script>

Vue 3.6 takes it further with alien signals -- a new reactive core library designed by the Vue team that benchmarks faster than every other signal implementation. The 3.6 reactivity refactor reduces memory an additional 14% and enables leaf-node updates: instead of re-evaluating entire component trees, Vue can update only the specific leaf-level nodes that changed.

Vue 3.6 also introduces Vapor Mode (experimental), which eliminates the virtual DOM for components that opt in, compiling templates directly to DOM operations like Solid. This gives Vue developers a migration path: keep the VDOM where it works, switch to Vapor Mode where they need maximum performance.

Alien signals: Vue's performance breakthrough

Alien signals is a reactive library created by Vue core team member Johnson Chu. It was designed from scratch to beat every existing signal implementation in benchmarks while maintaining Vue's API surface.

Key innovations:

  • Version-based tracking: instead of dirty flags, each reactive value has a version number. Subscribers compare versions to determine if recalculation is needed, reducing overhead for read-heavy workloads.
  • Link-list-based subscriptions: dependencies use a doubly-linked list instead of Sets, reducing allocation overhead and improving cache locality.
  • Lazy subscription cleanup: instead of eagerly removing stale subscriptions, alien signals use a deferred cleanup strategy that amortizes the cost across multiple update cycles.

Vue 3.6 with alien signals can mount 100,000 components in ~100ms, putting it in the same performance tier as Solid while maintaining Vue's familiar API.

What developers doWhat they should do
Destructuring reactive() objects to get individual values
Destructuring extracts plain values from the Proxy. The extracted values are not reactive. toRefs() creates refs linked to each property
Use toRefs() to convert reactive properties to refs, or use ref() from the start
Using ref() and forgetting .value in script setup
ref() wraps values in a { value: T } container. In JavaScript you must access .value. Templates have compiler magic to unwrap, but JS code does not
Always use .value in script/JS code. Templates auto-unwrap refs
Replacing a reactive() object instead of mutating properties
state = reactive(newObj) creates a new Proxy. Components tracking the old Proxy won't update. Mutation (state.prop = x) triggers the existing Proxy's set trap correctly
Mutate properties on the existing reactive object or use ref() for replaceable values
Key Rules
  1. 1Vue tracks reactivity at the per-property level using Proxy get/set traps, not at the whole-object level
  2. 2ref() is preferred for most reactive state -- it works with primitives, and .value makes reactivity explicit
  3. 3Vue's compiler optimizations (static hoisting, patch flags, block tree) make its VDOM approach nearly as fast as no-VDOM frameworks
  4. 4watchEffect() auto-tracks like an effect primitive, watch() gives explicit control with old/new values
  5. 5Vue 3.6 Vapor Mode offers a path to no-VDOM rendering for performance-critical components