createEffect: Synchronous vs Microtask Scheduling & Tracking Scopes
- Last updated
- Prerequisites:
- createSignal and reactive reads
- Understanding of effects vs derived values
- solidjs
- createEffect
- scheduling
- microtask
- tracking
- lifecycle
Read at your depth
The practical view
createEffect(() => { console.log(count()); }) runs the callback once initially, then re-runs it whenever any signal it reads changes. Effects are for side effects (logging, DOM APIs, subscriptions) — NOT for derived values (use createMemo for that). By default effects are scheduled: multiple signal changes in the same synchronous block trigger ONE effect run (batched), executed in a microtask. To run immediately and synchronously, pass the { name } or use createRenderEffect for pre-render effects. Reading signals inside the callback registers the dependency; reads outside (in helpers you call from the callback) are tracked too, as long as they happen synchronously during the callback.
The same idea in other frameworks
useEffect runs after commit, per render; Solid effects run on signal change (not re-render), with cleanup-before-rerun semantics.effect() in Angular signals is the analogue; Angular flushes during change detection.watchEffect is eager and flushes on the microtask queue by default, mirroring Solid's scheduling.$effect in Svelte 5 runs on the microtask after state changes; both compile away the dependency tracking into direct signal reads.Legacy vs modern
Side-effect in a memo vs side-effect in createEffect
Performing side effects in a derived computation is a bug (it may run zero or many times); effects are the designated place for side effects.
const x = createMemo(() => {
analytics.track('view'); // ❌ OLD: side effect in a pure memo
return count() * 2;
});createEffect(() => {
analytics.track('count', count()); // ✅ NEW: fires on every count change
});Interview gotchas
Context
This is the core scheduling question — interviewers want the batching and microtask semantics, which determine observable behavior in real event handlers and async code.
The mechanical answer
Effects are queued on a scheduler rather than run synchronously per change. When a signal setter marks an effect dirty, the effect is pushed to a queue, and a flush is scheduled via queueMicrotask. If several signals change in the same synchronous block (e.g. three setCount calls in one click handler), the effect is queued once (deduplicated) and runs exactly once when the microtask flush executes — after the current task completes, before the next macrotask. This collapses bursty updates into a single effect run. The effect reads the signals during its run, so it observes the final values, not intermediate ones. Synchronous effects (createRenderEffect) opt out of this batching.
Trap
The naive answer is 'effects run immediately when a signal changes'. They are batched and deferred to a microtask. Another trap: assuming an effect sees every intermediate value of a signal changed in a loop — it sees only the last value, because the callback runs once after the synchronous burst. Candidates who explain the scheduler queue and microtask flush show real understanding.
Context
This probes the tracking-scope mechanics — the global owner stack that makes dependency collection work across function calls, which interviewers use to test genuine understanding.
The mechanical answer
While an effect's callback executes, Solid pushes that computation onto a global tracking stack; any signal read — no matter how deep in a call stack the read happens — registers the current top-of-stack computation as a consumer. So calling a helper that reads count() inside the effect body tracks count as a dependency of the effect, even though the read is lexically elsewhere. This is why effects can wrap clean code that uses utility functions. The tracking stack is synchronous: if the callback awaits (deferring work), reads after the await happen outside the tracking scope and are not tracked. The dependency set is rebuilt on every run, so reads that no longer occur stop being dependencies.
Trap
The naive answer is 'only signals read directly in the callback body are tracked'. Reads in synchronously-called helpers are tracked because tracking is a stack, not lexical. Another trap: assuming async reads inside an effect are tracked — after an await, the tracking scope is gone, so those reads never register dependencies and the effect will not re-run for them. Candidates who explain the owner/tracking stack and its synchronous scope demonstrate real depth.