Skip to content

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.

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:

src/lib/scheduling-order.svelte.test.ts
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:

src/lib/derived-laziness.svelte.test.ts
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"]
From set() to a flushed effect

$.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:

src/lib/untrack-proof.svelte.test.ts
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();
}
// ...
}
src/lib/state-unsafe-mutation.svelte.test.ts
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.

  • Does a comment claim an $effect “runs synchronously, right after the set() that triggered it”? mark_reactions only schedules; the actual run happens on the next microtask flush (or the next explicit flushSync()) — never inside the same synchronous call stack as the write, unless the code itself calls flushSync.
  • Does code assume a $derived with 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 untrack used 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 — untrack permanently excludes that read from deps for that run of the effect.
  • Does a migration from $: doubled = x * 2 to $derived(x * 2) change behavior if the original statement had a side effect in it (a console.log, a mutation)? A $derived expression is expected to be pure — the state_unsafe_mutation guard 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.

After a click, when does the $effect above actually run relative to the onclick handler returning?
<script>
  let count = $state(0);
  $effect(() => {
    console.log(count);
  });
</script>
<button onclick={() => count++}>{count}</button>
What flag does mark_reactions set when it walks THROUGH a Derived reader (as opposed to an Effect reader)?
How many times has the derived's function run at this point?
let count = $state(0);
let doubled = $derived.by(() => { calls.push(count); return count * 2; });
count = 5;
// (no read of `doubled` yet)
What does untrack(() => b) inside an $effect do?
Why does mutating a DIFFERENT $state value from inside a $derived's evaluator throw state_unsafe_mutation?