Open-Source Wikis

/

Vue.js

/

Features

/

Reactivity system

vuejs/core

Reactivity system

The reactivity system is the foundation of Vue 3. This page traces a state mutation from the user's call site through the proxy traps, dep notification, scheduler, and renderer, and explains how each piece is wired together. For the API surface, see packages/reactivity. For the renderer side, see packages/runtime-core.

End-to-end flow

sequenceDiagram
    participant Code as user code
    participant Proxy as reactive Proxy
    participant Dep as Dep
    participant Effect as ReactiveEffect (component update)
    participant Sched as scheduler.ts
    participant Renderer as renderer.ts
    participant DOM
    Code->>Proxy: state.x = 1
    Proxy->>Dep: trigger(state, 'set', 'x')
    Dep->>Effect: notify()
    Effect->>Sched: queueJob(componentUpdate)
    Note over Sched: Promise.resolve().then(flushJobs)
    Sched->>Effect: run componentUpdateFn
    Effect->>Renderer: patch(prevTree, newTree)
    Renderer->>DOM: nodeOps + patchProp

The numbered steps:

  1. The user reads or writes a reactive proxy. Reads call track() (packages/reactivity/src/dep.ts), which links the active subscriber to the dep. Writes call trigger(), which walks the dep's subscriber list and notifies each one.
  2. Each subscriber decides what to do with the notification. A bare effect() re-runs immediately (or via its custom scheduler). A computed marks itself dirty; the next read recomputes. The component update effect re-queues itself via the scheduler.
  3. The scheduler (packages/runtime-core/src/scheduler.ts) batches jobs into a microtask flush. Jobs are sorted by component uid so parents render before children, and dedup'd via the QUEUED flag.
  4. On flush, each job re-runs its effect. The component update effect calls the render function, producing a new vnode tree, then invokes the renderer's patch().
  5. patch() (packages/runtime-core/src/renderer.ts) diffs old and new vnodes. Patch flags from the compiler tell it which subtrees have only static content and can be skipped.
  6. DOM mutations are pushed through the platform-specific nodeOps and patchProp.

Component update as a reactive effect

The component instance creation in setupComponent (packages/runtime-core/src/component.ts) culminates in setupRenderEffect (in renderer.ts). That function:

const componentUpdateFn = () => {
  if (!instance.isMounted) {
    // mount: render → patch(null, subTree, container)
  } else {
    // update: render → patch(prevTree, newTree, anchor)
  }
}

const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn,
  NOOP,
  () => queueJob(update),    // scheduler
  instance.scope,            // attach to component's EffectScope
))

const update = (instance.update = effect.run.bind(effect))
update()                     // initial mount

So the scheduler entry point for every component is queueJob(update). The update.id = instance.uid keeps parent-before-child order in the queue.

Auto-unwrapping refs in the template

Vue exposes setup state to templates via the public proxy (packages/runtime-core/src/componentPublicInstance.ts). The setup return value is wrapped in proxyRefs (packages/reactivity/src/ref.ts) so that ref values auto-unwrap when read through the proxy: count.value inside setup, count in the template. This is why type augmentations in runtime-core/src/index.ts add VNode and ComponentInternalInstance to RefUnwrapBailTypes — they should NOT be deeply unwrapped.

Dependency tracking under the hood

Dep (packages/reactivity/src/dep.ts) is a doubly-linked list of Link nodes. Each link carries:

  • dep — the dep this link belongs to.
  • sub — the subscriber.
  • prevDep / nextDep — sibling links in the subscriber's deps list.
  • prevSub / nextSub — sibling links in the dep's subscribers list.
  • version — used to detect stale links across runs.

When a ReactiveEffect runs, it sets activeSub to itself and bumps globalVersion. Each track() call either reuses an existing link (bumping its version) or allocates a new one. After the run, links whose version is stale are unlinked. This is how Vue achieves O(1) dependency cleanup per run instead of clearing+rebuilding the dep list.

graph LR
    sub["Subscriber<br/>(ReactiveEffect)"]
    l1["Link 1<br/>(dep: x)"]
    l2["Link 2<br/>(dep: y)"]
    depX["Dep for x"]
    depY["Dep for y"]
    sub --> l1
    l1 --> l2
    depX --> l1
    depY --> l2

Computed: lazy and cached

ComputedRefImpl (packages/reactivity/src/computed.ts) is both a Subscriber and a Dep:

  • As a Subscriber, it owns a ReactiveEffect whose body runs the getter.
  • As a Dep, it forwards its dirtiness to whoever is reading it.

A computed is DIRTY when:

  • It has never been evaluated (!EVALUATED).
  • One of its deps has changed since the last evaluation (globalVersion changed AND a dep is dirty).

The globalVersion short-circuit lets a chain of unaccessed computeds bail out of recomputation completely.

Scheduling deep dive

packages/runtime-core/src/scheduler.ts is small but careful. Key behaviors:

  • Single microtask flush. Promise.resolve().then(flushJobs) schedules at most one flush per tick. Jobs queued during the flush re-trigger another flush before yielding.
  • Sorted insertion. findInsertionIndex binary-searches by id. The "fast path" — appending at the tail when the new id is larger — handles most cases in O(1).
  • PRE flag for watchers. Pre-flush watchers (the default for watch with flush: 'pre') get the same id as their owning component but the PRE flag, so they sort just before the component's update.
  • Recursion limit. RECURSION_LIMIT = 100 aborts and warns when the same job re-queues itself more than 100 times in a single flush — the canonical "infinite update loop" detector.
  • Post-flush callbacks. pendingPostFlushCbs is drained after the main queue, then again at the end of each batch. mounted/updated/activated lifecycle hooks queue here so they run after every component in the batch is updated.

How patches read patch flags

When the compiler emits _createElementVNode("div", { class: foo }, …, 2 /* CLASS */), the renderer's patchElement only diffs the class prop, skipping every other prop. With 8 /* PROPS */ and a dynamicProps array, it only walks the listed keys. With 1 /* TEXT */, it only diffs the text child. With 4 /* STYLE */, only style. With 16 /* FULL_PROPS */, it walks everything (the bail case for v-bind="object").

STABLE_FRAGMENT (64) lets the renderer skip the keyed-children reconciliation entirely — child positions match by index. KEYED_FRAGMENT (128) routes through the LIS-based reorder algorithm in patchKeyedChildren. UNKEYED_FRAGMENT (256) does index-based matching with optional length truncation.

When tracking is paused

Two notable cases:

  • During a component's render function the renderer calls pauseTracking()/resetTracking() so re-reads from inside the render do not retrack the component effect against state it already tracks. This is the role of withCtx (packages/runtime-core/src/componentRenderContext.ts).
  • Inside transition hooks, mounting timers, and certain async paths, tracking is paused so transient reads don't accidentally subscribe.

The enableTracking/pauseTracking/resetTracking triplet from packages/reactivity/src/effect.ts is a stack-based control surface for this.

Files to know

  • packages/reactivity/src/ref.ts
  • packages/reactivity/src/reactive.ts
  • packages/reactivity/src/dep.ts
  • packages/reactivity/src/effect.ts
  • packages/reactivity/src/effectScope.ts
  • packages/reactivity/src/computed.ts
  • packages/reactivity/src/watch.ts
  • packages/reactivity/src/baseHandlers.ts
  • packages/reactivity/src/collectionHandlers.ts
  • packages/runtime-core/src/scheduler.ts
  • packages/runtime-core/src/apiWatch.ts
  • packages/runtime-core/src/renderer.ts (search for setupRenderEffect)

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Reactivity system – Vue.js wiki | Factory