Signals & Scheduling
Compiler Output showed that $state, $derived, and $effect all become calls that produce plain objects — a Source, a Derived, an Effect. This lesson is about what those objects actually are, how they’re wired together, and exactly when a write to one of them causes something else to run. Every function quoted below is real, current source from node_modules/[email protected]’s packages/svelte/src/internal/client/ — file and function name given so you can diff it against whatever version you’re reading.
Three kinds of node, one graph
Section titled “Three kinds of node, one graph”A Source (what $.state returns) is the object from Compiler Output: { f, v, reactions, equals, rv, wv }. A Derived extends that shape with deps (what it read last time it ran) and fn (the thunk to re-run). An Effect has deps too, plus what to actually do (fn) when it re-runs. All three carry an f flags field — a bitmask defined in packages/svelte/src/internal/client/constants.js:
// packages/svelte/src/internal/client/constants.js (trimmed to this lesson's flags)export const DERIVED = 1 << 1;export const EFFECT = 1 << 2;export const RENDER_EFFECT = 1 << 3;export const CLEAN = 1 << 10;export const DIRTY = 1 << 11;export const MAYBE_DIRTY = 1 << 12;export const USER_EFFECT = 1 << 20;$effect(fn) sets EFFECT | USER_EFFECT; $effect.pre(fn) sets RENDER_EFFECT | USER_EFFECT; a template’s own reactive text/attribute binding — the $.template_effect from Compiler Output — is a bare RENDER_EFFECT, no USER_EFFECT bit. That single bit is the entire difference between “your effect” and “the compiler’s own DOM-updating effect,” and between $effect and $effect.pre:
// packages/svelte/src/internal/client/reactivity/effects.js (trimmed)export function create_user_effect(fn) { return create_effect(EFFECT | USER_EFFECT, fn);}export function user_pre_effect(fn) { return create_effect(RENDER_EFFECT | USER_EFFECT, fn);}export function template_effect(fn, sync = [], async = [], blockers = []) { flatten(blockers, sync, async, (values) => { create_effect(RENDER_EFFECT, () => { fn(...values.map(get)); }); });}Render effects (RENDER_EFFECT — $effect.pre and every template binding) always run before plain effects (EFFECT — $effect) in the same flush. Proven directly:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('effect order: $effect.pre (render effect) runs before $effect (user effect)', () => { const log: string[] = []; const destroy = $effect.root(() => { let count = $state(0);
$effect.pre(() => { log.push(`pre:${count}`); }); $effect(() => { log.push(`effect:${count}`); });
flushSync(); log.length = 0;
count = 1; flushSync(); }); destroy();
expect(log).toEqual(['pre:1', 'effect:1']);});✓ effect order: $effect.pre (render effect) runs before $effect (user effect)
Test Files 1 passed (1) Tests 1 passed (1)Writing a source: mark_reactions propagates DIRTY, not “re-run now”
Section titled “Writing a source: mark_reactions propagates DIRTY, not “re-run now””set(source, value) (reactivity/sources.js) does not call anything — it records the new value and walks source.reactions (everything that has read this source), marking each one. This is the actual propagation function:
// packages/svelte/src/internal/client/reactivity/sources.js — mark_reactions (trimmed)function mark_reactions(signal, status, updated_during_traversal) { var reactions = signal.reactions; if (reactions === null) return;
for (var i = 0; i < reactions.length; i++) { var reaction = reactions[i]; var flags = reaction.f; var not_dirty = (flags & DIRTY) === 0;
if (not_dirty) set_signal_status(reaction, status);
if ((flags & DERIVED) !== 0) { // deriveds propagate onward as MAYBE_DIRTY — they don't re-run yet mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY, updated_during_traversal); } else if (not_dirty) { // effects get scheduled for the next flush schedule_effect(/** @type {Effect} */ (reaction)); } }}Two things to notice: a Derived reader is marked MAYBE_DIRTY and the walk continues through it to whatever reads that derived — a derived is never itself “scheduled,” because it has no side effect to run, only a value to recompute if and when something reads it. An Effect reader gets schedule_effect(effect) called — which does not run it either, it just queues it (next section). Nothing in this function calls a user’s function synchronously. That is the mechanical reason a synchronous run of $effect right after a set never happens.
$derived is lazy: update_derived only runs on read, via is_dirty
Section titled “$derived is lazy: update_derived only runs on read, via is_dirty”A fresh Derived starts life with DIRTY set and its value UNINITIALIZED — derived(fn) (reactivity/deriveds.js) never calls fn itself:
// packages/svelte/src/internal/client/reactivity/deriveds.js — derived (trimmed)export function derived(fn) { var flags = DERIVED | DIRTY; const signal = { deps: null, effects: null, equals, f: flags, fn, reactions: null, rv: 0, v: UNINITIALIZED, wv: 0 }; return signal;}The only thing that ever calls fn is execute_derived, and the only thing that calls that is update_derived, and the only thing that calls update_derived is is_dirty — which runs when (and only when) something reads the derived via $.get:
// packages/svelte/src/internal/client/runtime.js — is_dirty (trimmed)export function is_dirty(reaction) { if ((reaction.f & DIRTY) !== 0) return true;
if ((reaction.f & MAYBE_DIRTY) !== 0) { for (var dep of /** @type {Value[]} */ (reaction.deps)) { if (is_dirty(/** @type {Derived} */ (dep))) { update_derived(/** @type {Derived} */ (dep)); // <- only recomputed HERE, on read } if (dep.wv > reaction.wv) return true; } set_signal_status(reaction, CLEAN); } return false;}Proven with a call-count test — writing to a derived’s dependency never evaluates it; only reading it does, and reading it twice in a row (nothing changed in between) doesn’t re-run it a second time:
import { expect, test } from 'vitest';
test('$derived is evaluated lazily: the callback does not run until the value is READ, and is cached after that', () => { const calls: number[] = []; const destroy = $effect.root(() => { let count = $state(0); let doubled = $derived.by(() => { calls.push(count); return count * 2; });
count = 5; // write only — no read anywhere yet expect(calls).toEqual([]); // NOT evaluated just from writing the source
function read() { return doubled; } expect(read()).toBe(10); // first read expect(calls).toEqual([5]); // exactly one evaluation
expect(read()).toBe(10); // second read, nothing changed since expect(calls).toEqual([5]); // still one — cached, not re-run }); destroy();});✓ $derived is evaluated lazily: the callback does not run until the value is READ, and is cached after that
Test Files 1 passed (1) Tests 1 passed (1)This is the real reason $derived and a legacy $: x = ... are not interchangeable (Compiler Output showed the legacy statement compiles to an eagerly re-run effect): a $derived that’s never read simply never runs its expression, no matter how many times its dependencies change.
Flushing: Batch, one microtask, and flushSync
Section titled “Flushing: Batch, one microtask, and flushSync”schedule_effect (called from mark_reactions above) does one thing: Batch.ensure().schedule(effect) (reactivity/batch.js). The first write in a tick creates a Batch and queues one microtask to flush it:
// packages/svelte/src/internal/client/reactivity/batch.js — Batch.ensure (trimmed)static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); if (!is_processing && !is_flushing_sync) { queue_micro_task(() => { if (!batch.#started) batch.flush(); }); } } return current_batch;}queue_micro_task (dom/task.js) is a real queueMicrotask wrapper, deduplicated so a burst of synchronous writes schedules exactly one flush:
// packages/svelte/src/internal/client/dom/task.js — queue_micro_task (trimmed)export function queue_micro_task(fn) { if (micro_tasks.length === 0 && !is_flushing_sync) { var tasks = micro_tasks; queueMicrotask(() => { if (tasks === micro_tasks) run_micro_tasks(); }); } micro_tasks.push(fn);}That’s the whole batching story: every set() inside the same synchronous stretch of code shares the same Batch and the same queued microtask — which is why five count++ calls in a row produce one flush, not five. flushSync(fn) (same file) runs fn, then synchronously drains microtasks and flushes any batch in a loop until nothing is pending — this course’s harness uses it constantly to make an update happen now, inside a test, instead of waiting a tick:
// packages/svelte/src/internal/client/reactivity/batch.js — flushSync (trimmed)export function flushSync(fn) { is_flushing_sync = true; try { var result; if (fn) { if (current_batch !== null && !current_batch.is_fork) current_batch.flush(); result = fn(); } while (true) { flush_tasks(); if (current_batch === null) return result; current_batch.flush(); } } finally { is_flushing_sync = false; }}flowchart TD write["`set(`source, value`)`"] --> mark["mark_reactions DIRTY (effects) / MAYBE_DIRTY (through deriveds)"] mark --> sched["schedule_effect`(`effect`)` Batch.ensure`()`.schedule`(`effect`)`"] sched --> micro["queue_micro_task ONE queueMicrotask per batch"] micro --> flush["batch.flush`()` or flushSync`()` right now"] flush --> dirty["is_dirty`(`effect`)` recurses into MAYBE_DIRTY deps"] dirty -->|"a dep is a dirty Derived"| recompute["update_derived: fn`()` runs NOW (lazy — only because something read it)"] dirty --> run["update_effect: your `$effect`/`$effect.pre` fn runs"]
untrack: reading without subscribing
Section titled “untrack: reading without subscribing”$.get(signal) is where a dependency actually gets registered — it pushes the signal onto active_reaction.deps (runtime.js). untrack(fn) runs fn with the active-reaction bookkeeping suspended, so any $.get inside it reads the current value without adding a dependency:
import { flushSync, untrack } from 'svelte';import { expect, test } from 'vitest';
test('untrack: reading $state inside untrack() does not register a dependency', () => { const log: string[] = []; const destroy = $effect.root(() => { let a = $state(0); let b = $state(0);
$effect(() => { log.push(`a=${a} b(untracked)=${untrack(() => b)}`); });
flushSync(); log.length = 0;
b = 1; // b was only read via untrack — should NOT re-run the effect flushSync(); expect(log).toEqual([]);
a = 1; // a IS a real, tracked dependency flushSync(); expect(log).toEqual(['a=1 b(untracked)=1']); // re-ran, and b's CURRENT value is visible }); destroy();});✓ untrack: reading $state inside untrack() does not register a dependency
Test Files 1 passed (1) Tests 1 passed (1)Notice the effect still sees b’s up-to-date value (1) the next time it does run (triggered by a) — untrack doesn’t hide the value, it only stops that read from adding b to deps.
state_unsafe_mutation: a real, deliberate guard
Section titled “state_unsafe_mutation: a real, deliberate guard”Writing a $state value from inside a $derived’s own evaluator is not a style warning — set() (reactivity/sources.js) checks the currently active reaction’s flags and throws if it’s a DERIVED (among a few other reaction kinds) doing an unrequested write to a different source while it’s supposed to be a pure computation:
// packages/svelte/src/internal/client/reactivity/sources.js — set (trimmed)export function set(source, value, should_proxy = false) { if ( active_reaction !== null && (!untracking || ...) && is_runes() && (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 && (current_sources === null || !current_sources.has(source)) ) { e.state_unsafe_mutation(); } // ...}import { expect, test } from 'vitest';
test('state_unsafe_mutation: writing to $state from inside a $derived is a real thrown error', () => { const destroy = $effect.root(() => { let count = $state(0); let bad = $derived.by(() => { count = count + 1; // @expect-error state_unsafe_mutation return count; });
expect(() => bad).toThrow(/state_unsafe_mutation|Writing to state/i); }); destroy();});✓ state_unsafe_mutation: writing to $state from inside a $derived is a real thrown error
Test Files 1 passed (1) Tests 1 passed (1)This is the runtime enforcing “a derived is a pure computation of its dependencies” — the same rule svelte.dev states in prose, backed here by a thrown, named error.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a comment claim an
$effect“runs synchronously, right after theset()that triggered it”?mark_reactionsonly schedules; the actual run happens on the next microtask flush (or the next explicitflushSync()) — never inside the same synchronous call stack as the write, unless the code itself callsflushSync. - Does code assume a
$derivedwith an expensive computation “runs on every dependency change,” and therefore adds manual memoization on top of it? It’s already lazy and cached — proven above: writing a dependency never evaluates it, only reading the derived does, and repeated reads without an intervening write don’t re-run it. - Is
untrackused to “read the latest value” inside an effect, with the expectation that the effect will still re-run when that value changes later? It won’t —untrackpermanently excludes that read fromdepsfor that run of the effect. - Does a migration from
$: doubled = x * 2to$derived(x * 2)change behavior if the original statement had a side effect in it (aconsole.log, a mutation)? A$derivedexpression is expected to be pure — thestate_unsafe_mutationguard above is exactly the runtime catching one common way that assumption gets violated.
🐛 Spot the bug
<script>let user = $state({ name: 'Ada', visits: 0 });
// "log a visit every time the greeting is computed"let greeting = $derived.by(() => { user.visits += 1; return `Hello, ${user.name}`;});</script>
<p>{greeting}</p>Show the answer
user.visits += 1 mutates $state from inside a $derived’s evaluator function. The active reaction while this thunk runs is the Derived itself (flagged DERIVED), and set()’s guard checks exactly this case — this throws a real state_unsafe_mutation error the moment the derived is first read (proven above with the identical shape: a derived writing to $state during its own evaluation). The fix is to not treat a derived’s evaluator as a place for side effects at all — counting visits belongs in an $effect (which is allowed to write state, subject to its own re-run rules), not in a value that is supposed to be a pure function of its inputs.