Read OSS

The Reactivity Engine: Sources, Deriveds, Effects, and the Batch Scheduler

Advanced

Prerequisites

  • Article 1: Architecture and Codebase Map
  • Article 2: Compiler Pipeline (understanding compiled output format)
  • Reactive programming basics (signals, dependency tracking)
  • Bitwise operations (flags, masks, &, |, ^)
  • ES Proxy handler traps

The Reactivity Engine: Sources, Deriveds, Effects, and the Batch Scheduler

Svelte 5's reactivity engine is a pull-based signal system. When a source value changes, it doesn't immediately propagate — it marks dependent reactions as potentially dirty, and they re-evaluate lazily when the scheduler gets to them. This design enables derived values to skip unnecessary recomputation and keeps update batching efficient. Let's look at how it works, starting from the data structures.

The Signal Graph: Sources, Deriveds, and Effects

There are three reactive primitives, all defined as plain JavaScript objects with a shared shape:

Sources are the entry points — the leaves of the reactive graph. Created by $.state(), a source holds a value and tracks which reactions depend on it. From sources.js:

export function source(v, stack) {
    var signal = {
        f: 0,          // flags
        v,             // value
        reactions: null, // dependents
        equals,        // equality function
        rv: 0,         // read version
        wv: 0          // write version
    };
    return signal;
}

Deriveds are lazy computations — both readers and writers in the graph. Created by $.derived(), they have the same fields as sources plus a fn (the computation function), deps (upstream dependencies), and parent/child pointers. From deriveds.js:

export function derived(fn) {
    var flags = DERIVED | DIRTY;
    // ...creates an object with fn, deps, parent, first, last, etc.
}

Effects are side-effect functions that form a tree. Created by create_effect() in effects.js, they include DOM node references and tree pointers:

var effect = {
    ctx: component_context,
    deps: null,
    nodes: null,       // { start, end } DOM anchors
    f: type | DIRTY | CONNECTED,
    first: null,       // first child effect
    fn,                // the effect function
    last: null,        // last child effect
    next: null,        // next sibling effect
    parent,            // parent effect
    prev: null,        // previous sibling effect
    teardown: null,    // cleanup function
    wv: 0,
    ac: null           // AbortController
};
graph TD
    subgraph Sources
        S1["source { v: 0, reactions: [...] }"]
        S2["source { v: 'hello', reactions: [...] }"]
    end
    subgraph Deriveds
        D1["derived { fn, deps: [S1, S2], reactions: [...] }"]
    end
    subgraph Effects
        E1["root_effect { first → E2 }"]
        E2["render_effect { deps: [D1], parent → E1 }"]
    end
    S1 -->|"reactions"| D1
    S2 -->|"reactions"| D1
    D1 -->|"reactions"| E2
    E1 -->|"first"| E2

The Bitwise Flag System

Every reactive node has a .f (flags) integer field. Status checks use bitwise operations, which are significantly faster than property lookups or string comparisons. The flags are defined in constants.js:

Flag Bit Purpose
DERIVED 1 << 1 Node is a derived computation
EFFECT 1 << 2 Node is a user effect
RENDER_EFFECT 1 << 3 Node is a render effect (synchronous)
BLOCK_EFFECT 1 << 4 Effect that preserves children across reruns
BRANCH_EFFECT 1 << 5 Effect representing a conditional branch
ROOT_EFFECT 1 << 6 Top-level effect (mount point)
BOUNDARY_EFFECT 1 << 7 Error/suspense boundary
CONNECTED 1 << 9 Connected to the effect tree
CLEAN 1 << 10 Value is up-to-date
DIRTY 1 << 11 Value needs recomputation
MAYBE_DIRTY 1 << 12 Upstream might have changed
INERT 1 << 13 Paused (offscreen, transitioning out)
DESTROYED 1 << 14 Permanently removed

Checking a flag is a single bitwise AND: (reaction.f & DIRTY) !== 0. Setting uses OR: reaction.f |= DIRTY. Clearing uses AND-NOT: reaction.f &= ~DIRTY. These operations compile to single CPU instructions.

Tip: When debugging the reactivity engine, you can decode a .f value by checking it against the flag constants. For example, f = 2054 in binary is 100000000110, which is DIRTY | CONNECTED | DERIVED.

Dependency Tracking: get() and the Reaction Context

The get() function is the heart of dependency tracking. Every time a reactive value is read — whether it's a source, derived, or proxy property — get() is called. It checks if there's an active_reaction (the currently executing effect or derived) and, if so, registers a dependency:

export function get(signal) {
    if (active_reaction !== null && !untracking) {
        // ...register dependency
        if (signal.rv < read_version) {
            signal.rv = read_version;
            if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
                skipped_deps++;
            } else if (new_deps === null) {
                new_deps = [signal];
            } else {
                new_deps.push(signal);
            }
        }
    }
}

The rv (read version) and wv (write version) fields are an optimization to avoid duplicate dependency registrations. Each reaction execution increments a global read_version. If a signal's rv matches the current read_version, it was already registered in this execution cycle and can be skipped.

The skipped_deps optimization is particularly clever: if an effect reads the same dependencies in the same order as the previous execution, it increments skipped_deps instead of allocating a new array. Only when the dependency order diverges does it start building new_deps. This means most re-runs produce zero garbage.

sequenceDiagram
    participant Effect as Active Effect
    participant Get as get()
    participant Source as Source Signal

    Effect->>Get: read source.value
    Get->>Get: active_reaction !== null?
    Get->>Get: signal.rv < read_version?
    Get->>Get: deps[skipped_deps] === signal?
    alt Same order as last run
        Get->>Get: skipped_deps++
    else New dependency
        Get->>Get: new_deps.push(signal)
    end
    Get-->>Effect: return signal.v

The module-level variables active_reaction, active_effect, untracking, and current_sources at runtime.js#L73-L95 form the execution context. This is the same module-level state pattern we saw in the compiler (Article 2), used here for the same reason: performance in hot loops.

Executing Reactions: update_reaction()

The update_reaction() function is the core execution loop. It runs a reaction's function while carefully managing the dependency tracking context:

export function update_reaction(reaction) {
    // Save all context variables
    var previous_deps = new_deps;
    var previous_reaction = active_reaction;
    // ... 6 more saves

    // Set up new context
    new_deps = null;
    skipped_deps = 0;
    active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
    set_component_context(reaction.ctx);

    try {
        var result = reaction.fn();
        // Reconcile dependency arrays...
    } finally {
        // Restore all context variables
    }
}

The save-and-restore pattern ensures that reactions can be nested (effects inside effects, deriveds evaluated during effect execution) without corrupting the tracking context. After the function runs, it reconciles the dependency list — removing reactions from signals that are no longer dependencies and adding reactions to newly discovered ones.

flowchart TD
    Start["update_reaction(reaction)"] --> Save["Save 8 context variables"]
    Save --> Setup["Set active_reaction,<br/>reset new_deps, skipped_deps"]
    Setup --> Run["Execute reaction.fn()"]
    Run --> Reconcile{"new_deps !== null?"}
    Reconcile -->|yes| Update["Update deps array,<br/>register reactions on new deps"]
    Reconcile -->|no| Trim["Trim deps to skipped_deps length"]
    Update --> Restore["Restore 8 context variables"]
    Trim --> Restore

The Effect Tree Structure

Effects don't live in a flat list — they form a tree via parent-child linked lists. The first/last pointers connect an effect to its children. The prev/next pointers create a sibling chain. The parent pointer links back up.

This tree structure mirrors the component tree. A component's root effect contains render effects for each template region. Control flow blocks ({#if}, {#each}) create branch effects as children. The different effect types define the tree's behavior:

  • ROOT_EFFECT — created by mount(), owns the entire component
  • RENDER_EFFECT — synchronous effects for DOM updates, run before user effects
  • EFFECT — user effects ($effect), run after DOM updates
  • BLOCK_EFFECT — wraps control flow; preserves children across reruns
  • BRANCH_EFFECT — a conditional branch within a block (one arm of an {#if})
graph TD
    Root["ROOT_EFFECT<br/>(component mount)"]
    Root --> RE1["RENDER_EFFECT<br/>(template setup)"]
    Root --> Block["BLOCK_EFFECT<br/>({#if condition})"]
    Block --> Branch1["BRANCH_EFFECT<br/>(true branch)"]
    Branch1 --> RE2["RENDER_EFFECT<br/>(update text)"]
    Block --> Branch2["BRANCH_EFFECT<br/>(false branch)"]
    Root --> UE["EFFECT<br/>($effect user code)"]

When a block's condition changes, the old BRANCH_EFFECT is paused (allowing outro transitions to run) and a new branch is created. The block effect itself is never destroyed — it just switches which branch is active.

Dirty Checking: The CLEAN/MAYBE_DIRTY/DIRTY State Machine

The three status flags — CLEAN, MAYBE_DIRTY, and DIRTY — form a state machine that enables lazy evaluation. The is_dirty() function determines whether a reaction needs re-execution:

  • DIRTY — a direct dependency changed. Definitely needs re-execution.
  • MAYBE_DIRTY — an upstream derived might have changed. Check dependencies first.
  • CLEAN — nothing changed. Skip.

When a source is written to, it marks its direct dependents as DIRTY (if they're effects) or propagates MAYBE_DIRTY upward (if the dependent is a derived). This is the "push" phase. The "pull" phase happens in is_dirty():

export function is_dirty(reaction) {
    if ((flags & DIRTY) !== 0) return true;

    if ((flags & MAYBE_DIRTY) !== 0) {
        var dependencies = reaction.deps;
        for (var i = 0; i < dependencies.length; i++) {
            var dependency = dependencies[i];
            if (is_dirty(dependency)) {
                update_derived(dependency);
            }
            if (dependency.wv > reaction.wv) {
                return true;
            }
        }
        set_signal_status(reaction, CLEAN);
    }
    return false;
}

For MAYBE_DIRTY reactions, it recursively checks each dependency. If a derived dependency is itself dirty, it's re-evaluated. Then the write version (wv) is compared. If no dependency has a newer wv than the reaction's wv, the reaction is clean — no work needed.

This is the key performance insight: a derived value that depends on another derived doesn't recompute unless the upstream value actually changed. The MAYBE_DIRTY state defers evaluation until someone actually needs the value.

The Batch Scheduler

The Batch class orchestrates how reactive updates are processed. When a source is written to, it calls Batch.ensure() to get or create the current batch, then schedules affected effects.

A batch collects signal changes and defers processing to a microtask. This means multiple synchronous writes are batched into a single update:

count = 1;  // schedules batch
count = 2;  // same batch
count = 3;  // same batch
// microtask fires: process all changes once

The batch maintains several data structures:

  • current — a Map<Value, [any, boolean]> of current signal values in this batch
  • previous — a Map<Value, any> of values before the batch started
  • #roots — root effects that need flushing
  • #commit_callbacks — DOM operations deferred until commit

Processing walks the effect tree top-down, collecting dirty effects. Render effects (synchronous DOM updates) run first, then user effects. This ordering ensures the DOM is consistent before any user $effect code executes.

The batch system also supports forks — parallel batches for concurrent rendering. A fork has its own set of signal values and can be committed or discarded independently. This enables the experimental async rendering mode where new branches can render in the background while the current UI remains visible.

Tip: flushSync() forces all pending batches to process immediately. It's useful in tests and when you need the DOM to be up-to-date before reading from it.

Deep Reactivity via ES Proxy

When you write let obj = $state({ x: 1 }), the compiler generates $.proxy(...) to wrap the object in an ES Proxy. The proxy() function only wraps plain objects and arrays — class instances, DOM elements, and other exotic objects pass through unchanged:

export function proxy(value) {
    if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
        return value;
    }
    const prototype = get_prototype_of(value);
    if (prototype !== object_prototype && prototype !== array_prototype) {
        return value;
    }
    // Create proxy with lazy source creation...
}

The proxy creates source signals lazily — one per property, allocated on first access. When you read obj.x, the proxy's get trap calls get() on the corresponding source signal, establishing a dependency. When you write obj.x = 2, the set trap calls set() on that source, triggering reactivity.

This lazy approach means an object with 100 properties doesn't create 100 sources upfront — only the properties that are actually read in reactive contexts get tracked. A version source is bumped on structural changes (adding/deleting properties, array mutations) to handle iteration and Object.keys().

flowchart TD
    Proxy["ES Proxy for { x: 1, y: 2 }"]
    Proxy -->|"get trap: obj.x"| GetX["sources.get('x') ?? create source"]
    GetX --> Signal["get(source) → registers dependency"]
    Proxy -->|"set trap: obj.x = 3"| SetX["set(source, 3) → triggers reactivity"]
    Proxy -->|"ownKeys trap"| Version["get(version) → tracks structural changes"]

What's Next

The reactivity engine provides the "what to update" — but how does Svelte actually create and update the DOM? In the next article, we'll explore the template instantiation system that uses cloneNode() for fast DOM creation, the control flow block implementations ({#if}, {#each}), and the hydration protocol that lets client-side Svelte pick up where server-rendered HTML left off.