This week, while adding a small feature to our CMS, we shipped a bug that never should have existed. The code passed type checking, passed 1,300+ tests, and worked in the demo. It was also built on a Svelte primitive that the framework's own documentation politely describes as "something of an escape hatch," and it failed in exactly the way the documentation warns about.
The primitive is $effect
. The interesting part isn't the bug. It's why the code got written that way in the first place: the same reflex that produced it is shaping code written by thousands of developers and, increasingly, by AI coding agents. Ours included.
A reflex imported from React #
If you learned modern frontend development in the last decade, there's a good chance you learned React first. And React taught a generation of developers one big idea about reactivity: when something changes, run a function. useEffect
became the hammer for everything: deriving state, syncing state, fetching data, subscribing, resetting forms. The React team has spent years walking that back ("You Might Not Need an Effect" is practically required reading now), but the muscle memory is deep.
Svelte 5's $effect
looks like useEffect
. It's named like it. It even re-runs on dependency changes like it. So the reflex transfers: state changed, so I'll write an effect to update this other state.
But Svelte is built on a different model. Reactivity is declarative and pull-based: $derived
values recompute lazily, only when read, only when their dependencies actually changed. The framework wants you to describe what a value is, not when to update it. $effect
exists for the boundary where declarative reactivity ends: drawing on a canvas, talking to a third-party library, managing a timer. Using it to synchronize one piece of state with another means fighting the framework with the framework's own tools.
There's a second population with this exact reflex: AI coding agents. Training corpora are saturated with a decade of React, including every useEffect
-to-sync-state pattern ever committed to GitHub. Ask an agent to "seed this form from server data" in a Svelte project and there's a decent chance you get an effect, because in the training distribution, that's what seeding state looks like. The author of the effect that started this post was an AI agent. The reviewer who flagged it was a human who read the docs.
What it costs you #
State-syncing effects aren't just stylistically wrong. They have a real runtime cost profile:
- Extra update passes. Effects run
afterrendering. An effect that writes state schedules another round of invalidation and re-rendering after the one that just finished. Chain a few together and one user interaction becomes a waterfall of update cycles, each waiting for the previous flush. - No laziness, no memoization. A
$derived
recomputes only when read, and short-circuits downstream updates when the value is referentially unchanged. An effect runs every time its dependencies tick, whether or not anyone needed the result. - Loops and races. An effect that reads and writes overlapping state is one refactor away from an infinite loop; Svelte will throw
at you if you're lucky. Less lucky: theeffect_update_depth_exceeded
orderingraces. Our bug was one of these. An effect seeded local form drafts from server data, and a save handler cleared the draftsbeforerefreshing the data, so the effect re-seeded from stale values. No error, no crash. Just quietly wrong data in the form. - SSR blind spots. Effects don't run during server-side rendering. State computed in an effect simply doesn't exist in the server-rendered output.
The audit: 72 call sites #
After fixing our bug (the idiomatic version: component-local state initialized from props, remounted via {#key}
when the server value changes, no effect at all), we did what you should do after any "how did this happen" moment: we went looking for the rest.
Our CMS had 72 real $effect
call sites across 46 files. The verdict after a full review:
- Two were state-sync effects that converted directly to
$derived
or an attachment. One became the bug fix above. - The rest were legitimate: contenteditable DOM seeding, popover positioning, autosave timers, canvas rendering,
localStorage
persistence. Genuine side effects with no declarative home.
That ratio is worth sitting with. $effect
isn't evil, and a codebase full of them isn't necessarily wrong; imperative boundaries are real. The problem is unexamined effects: nobody had ever asked, of each one, "is this actually a side effect, or is it derived state wearing a trench coat?" (The audit also shook loose an unrelated copy-pasted bug in ten files: a ===
where an =
belonged, silently defeating a state reset for years. Audits pay for themselves.)
Svelte gives you a ladder of better tools, and almost every rung got used in our sweep:
for anything computable from other state. Writable since Svelte 5.25, which covers the "derived but user-overridable" case too.$derived
Function bindings(bind:value={() => get, set}
) for linked inputs.(Svelte 5.29+) for element-scoped DOM setup. The attachment receives the element, re-runs when its reactive inputs change, and cleans up via its return function. Our QR-code component went from{@attach}
bind:this
plus an effect to a five-line attachment.and<svelte:window>
for global listeners.<svelte:document>
- Prop-seeded local state plus
{#key}
for form drafts that a server value should reset. $effect
, last, and with a comment explaining why nothing above fit.
Lint what you learned #
A review finding you don't encode is a review finding you'll make again, especially when some of your contributors are agents that will happily reproduce the training-data reflex tomorrow. So we turned the lesson into tooling.
We use Fallow for dead-code and architecture auditing, and its rule packs support banning specific calls with a custom message. Ours now includes:
{
"id": "svelte-effect-last-resort",
"kind": "banned-call",
"callees": ["$effect", "$effect.pre", "$effect.root"],
"files": ["src/**/*.svelte", "src/**/*.svelte.ts"],
"message": "$effect is a last resort — reach for the Svelte-native primitive first: $derived for computed values, {@attach} for element-scoped DOM setup, <svelte:window> for global listeners…",
"severity": "error"
}
fallow audit
runs in CI and in our release workflow, and it gates on findings introduced by the changeset: new $effect
calls fail the build, while the 70 audited, suppressed-with-rationale sites don't. That "introduced-only" semantics matters. You can adopt a strict rule in a mature codebase today without first paying down every historical instance.
Two details made this work well with AI contributors. First, the rule's message teaches the alternative at the moment of violation. An agent that hits the gate gets the decision ladder in its error output, and in our experience that's usually enough for it to fix its own patch. Second, every surviving effect carries a one-line rationale comment. The next reader, human or agent, inherits the judgment, not just the code.
The takeaway #
$effect
is a fine tool for the job it was designed for. The trap is the imported reflex, state changed, run a function, that a decade of React installed in developers and, through them, in the models now writing a growing share of our code. The fix is the same for both: know the ladder, reach for $derived
first, and put a gate in CI that remembers the lesson even when you don't.