Writing reactive state inside a getter called by a $derived crashes the whole render tree, not just that component
In Svelte 5's strict runtime, writing to a $state field — even indirectly, through a "getter" method that does a lazy rehydrate on first access — while a $derived is being (re)computed throws state_unsafe_mutation and aborts the entire render tree, not just the derived value.
The signal that gives it away
A deterministic blank/black screen on hard refresh, specifically when some persisted state (sessionStorage, localStorage) is already populated from a previous visit. It won't reproduce on a cold browser with empty storage — only when there's something to lazily load. The console shows state_unsafe_mutation, and the stack trace bottoms out not in an effect or event handler but inside a $derived.by(() => ...) computation, often several calls removed from the actual assignment (e.g. a list/rows computation calling a store method that calls a private helper that does the write).
Structurally, look for a class-based store with a method that reads *and conditionally writes* its own backing $state, shaped like this:
```typescript class EntryStore { #entries = $state<Map<string, Entry[]>>(new Map());
// looks like an innocent getter — it is not entries(key: string): Entry[] { const cached = this.#entries.get(key); if (cached) return cached; const stored = loadFromStorage(key); // lazy rehydrate if (stored.length > 0) { const next = new Map(this.#entries); next.set(key, stored); this.#entries = next; // ❌ WRITE, hidden inside a "read" return stored; } return []; } } ```
...consumed from a component like rows = $derived.by(() => [...list, ...store.entries(key)]). The bug is invisible in code review unless you specifically check every method named like a getter for a hidden write — it reads as pure from the call site.
How to exploit it (i.e. how to fix it)
1. Know the exact rule before you start grepping. Svelte 5's runtime tags the current reaction; writes to $state are permitted only from: top-level component script (init), $effect() / $effect.pre() callback bodies, event handlers (onclick, oninput, ...), async functions called from an effect/handler, and promise resolutions that originate from an effect/handler. Writes are forbidden from: $derived(...) / $derived.by(...) bodies, $inspect(...) bodies, template expressions, and — the non-obvious part — any "pure-looking" function called *from* one of those, even transitively through two or three layers of indirection.
2. Grep for the shape, don't wait for the crash to tell you where. In the stores directory: grep -rnE "this\.#?\w+\s*=" src/lib/stores/*.svelte.ts, then manually exclude constructors and already-imperative methods. What's left is your candidate list of "getter-shaped" methods that secretly write. This is fast and catches the pattern before it ships, since the crash itself only manifests when storage happens to be populated at first render — easy to miss in a clean-state dev session.
3. Split into three layers — don't just delete the write. The write is usually needed (you want the lazy-loaded data cached in state so future reads are fast); it just needs to move out of the read path:
- Layer 1 — pure read. The method callable from $derived/template/$inspect does *only* return this.#entries.get(key) ?? [] — zero side effects, guaranteed safe from any calling context.
- Layer 2 — private unchecked mutator. A #rehydrateUnchecked(key) that does the actual storage read + $state reassignment. Not exported; only ever called from contexts already known to be safe.
- Layer 3 — public idempotent imperative method. rehydrate(key): checks if (this.#entries.has(key)) return; first, then calls the private mutator. Idempotency here isn't optional polish — it's what makes it safe to call this from onMount, from a route's async load function, *and* from an event handler that might race with the mount, without double-loading or clobbering newer in-memory state with stale storage.
- Wire layer 3 explicitly from an async context that already runs after mount/navigation (e.g. the function that loads a session's data), not from inside the derived that consumes the data.
4. Do not reach for `untrack()` — it does not fix this. untrack() (from svelte) only exempts *reads* from being registered as dependencies of the derived; it has no effect on the write-permission check. Wrapping the write in untrack(() => { this.#entries = next; }) inside a derived still throws state_unsafe_mutation. This is worth stating explicitly because untrack is the first thing people try, and its name makes it sound like the right tool.
5. Fix the storage read defensively while you're in there. Any function that reads persisted state to rehydrate should validate the parsed shape and delete the storage key on *any* failure — malformed JSON, wrong schema, access denied (privacy mode / quota) — not just on parse errors. If it doesn't self-clean, a single corrupted entry causes the exact same crash on every subsequent refresh (a crash-loop from a one-time storage corruption), which is much harder to diagnose than the original bug because it now looks intermittent-per-user rather than reproducible.
6. Verify with a real hard-refresh test, not a unit test. Populate storage, hard-refresh (F5), and confirm: page mounts normally, the previously-persisted data appears within roughly one paint (tens of ms), and the console shows zero state_unsafe_mutation. A component test that mounts fresh (empty storage) every time will pass regardless of whether this bug exists, because the bug is conditional on storage already being populated *before* first derived-compute — exactly the condition a clean-slate test harness never creates.
7. If you can, confirm the fix actually shipped, not just that the source changed: grep the built/bundled output for a marker unique to the new method name. Source-level review can miss stale builds or a second call site that still uses the old getter-with-side-effect shape.
What does NOT work
- `untrack()` around the write. As above: it silences dependency-tracking on reads, it does not grant write permission inside a derived. The error still throws.
- Catching the exception around the render call. The mutation-in-derived error aborts the render tree at the point of computation; wrapping the outer render call in try/catch doesn't give you a working page, it gives you a working page *without* the feature that needed the derived, or nothing at all depending on where in the tree the derived sits. Fix the write location, don't try to swallow the symptom.
- Moving the write into an `$effect` without making it idempotent. This removes the state_unsafe_mutation error (effects are a legal write context) but if the effect reads the same state it's populating and isn't guarded to skip when already loaded, you trade a deterministic crash for a re-render loop — see An $effect that reads state it indirectly causes to be written re-fires forever, silently. The idempotency check (if already cached, return) is not optional cleanup, it's load-bearing.
- Testing only with empty storage. The bug is invisible in that condition. Any regression test for this class of issue needs to seed storage *before* mount, not assert against a cold start.
- Assuming a getter is safe because it "just returns a value." The call site can't tell a pure getter from one with a hidden lazy-rehydrate write without reading the method body; the crash only surfaces at the specific moment the write path executes inside a derived, which can be much later than when the method was written or last touched.
See also
- An $effect that reads state it indirectly causes to be written re-fires forever, silently — the other direction of the same rule: a write context ($effect) that's legal but re-triggers itself because it reads the state it writes
- Subscribe-only store — invisible to whoever mounts after the event already fired — a different rehydrate-on-mount failure in the same class of store: missing initial hydration instead of hydration happening in the wrong (illegal) place
- Insertion-sort per-entry on merge is fragile — use one stable final sort instead — a second, independent bug that shipped in the same fix as this one (ordering, not crashing), from the same root cause of merging a lazily-rehydrated collection into a derived list
Verified against
18 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.