A plead to stop using $effect
$effect is an escape hatch, not a way of life.$effect is a helpful escape hatch once you've gone too far down an incorrect code path or need to hack around things you can't control, but it's not meant to be a way of life.
No one in the Svelte community will disagree with that, but I'm not writing this for them, I'm writing this for the agents that can't seem to figure this out. When I generate Svelte code with my agents it's littered with $effects, it's filthy. It's time they learned better, and that's what I hope to do with this post.
Why you shouldn't use $effect
$effect is powerful but also dangerous. It's far too easy to create cyclic dependencies that cause infinite loops. Using an $effect is almost always a sign that the code should be refactored.
It also adds unnecessary complexity and makes code far more difficult to review than if it was written with the event-based equivalents. This is all because when reading an $effect you need to understand how every variable in that $effect changes. It's much harder to hold in your mind than a simple onChange function.
Lastly, it's just more code in the script tag. Script tags are already bloated enough holding any state and handler functions. $effects just pile on to that and take away from what the real focus is in your .svelte files, the markup.
How agents get it wrong
There are a few ways I see agents using $effect that are making the Svelte code they write worse.
1. Using $effect in place of $derived or $derived.by
Often times agents will use $effect where a $derived or $derived.by rune would be better suited for the job.
Now I can't tell you exactly what was happening on the GPUs when this was generated by the agent, but my assumption is that it was trying to squash the warning emitted by Svelte here.
// Svelte Warns: This reference only captures the initial value of `data`. Did you mean to reference it inside a derived instead?
let rowsPerPageValue = $state(String(data.query.rows));
$effect(() => {
rowsPerPageValue = String(data.query.rows);
});
The agent uses an $effect to derive rowsPerPageValue from data.query.rows which works but is far more code (and complexity) than we need.
The correct code would look like:
// if data.query.rows will change
let rowsPerPageValue = $derived(String(data.query.rows));
// or suppress the warning if data.query.rows won't change
// svelte-ignore state_referenced_locally
let rowsPerPageValue = $state(String(data.query.rows));
2. Using $effect to react to state changes
This is a perfectly valid use of $effect but it's unnecessarily brittle and honestly just a code smell. It's far cleaner to react to events as they happen instead of trying to hook into the state change. Doing things this way is kinda like trying to drive the car while riding on the outside of it.
Take this code written by an agent:
<script lang="ts">
let currentPage: number = $state(data.query.page);
$effect(() => {
navUpdate({ page: currentPage });
})
</script>
<Select bind:value={currentPage} {...}/>
This code works but we can remove the $effect by simply reacting to this change using onChange functions from the <Select/> component instead:
<script lang="ts">
let currentPage: number = $state(data.query.page);
</script>
<Select
bind:value={currentPage}
onValueChange={(v) => navUpdate({ page: v })}
{...}
/>
3. Missing out on key features of bind:
bind: is a powerful concept in Svelte and got even more powerful with the addition of getter/setter bindings. But in my experience the agents don't really know about this feature and will use $effect to work around it.
Take this code written by an agent as an example. We have a Select whose type is string but we need to get a number out of it (knowing its options are also numbers).
<script lang="ts">
let query: { rowsPerPage: number } = $state({ rowsPerPage: 10 });
let rowsPerPageValue = $state(String(query.rowsPerPage));
$effect(() => {
const v = rowsPerPageValue;
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || n === query.rowsPerPage) return;
query = { rowsPerPage: n };
})
</script>
<!-- The typeof value is string -->
<Select bind:value={rowsPerPageValue} {...}/>
Putting aside some of the very obvious slop around the guard clause and unnecessary variable declarations, the agent also misses that it can use getters and setters in its binding here to eliminate most of that code it just wrote.
This could be better written with getters and setters to do the type conversion in-line:
<script lang="ts">
let query: { rowsPerPage: number } = $state({ rowsPerPage: 10 });
</script>
<!-- The typeof value is string -->
<Select
bind:value={
() => query.rowsPerPage.toString(),
(v) => { query.rowsPerPage = Number.parseInt(v, 10) }
}
{...}
/>
Starting from a better foundation
When writing your code, don't react to state changes, react to events. Scrutinize code paths that force you into using $effect and find ways to make them event-based instead.
An explanation of
$effect
$effect is a powerful tool to react to state changes, but it should be avoided most of the time and only ever used as an escape hatch. $effect tracks all variables within its closure and fires when one of them changes. You can use the untrack method to prevent variables from being tracked by the $effect.
If you absolutely must use $effect and there is no other way, consider using something like watch from the runed library.
This allows you to declare a dependency array helping reduce footguns by reducing the chance you create a cyclic dependency.
$derived
$derived allows you to create state that reacts to changes in other state. For example creating a multiplied count:
let count = $state(0);
const doubled = $derived(2 * count);
One thing you might have missed is that you can also declare $derived using let and assign to it so that you can have state that is bindable but also reactive:
<script lang="ts">
let name = $derived('');
let displayName = $derived(name);
</script>
<input type="text" bind:value={name} />
<input type="text" bind:value={displayName} />
$derived.by
$derived.by is useful but far less common than $derived. $derived.by is more useful when you want to derive from something that can't just be declared in a single expression:
<script>
let numbers = $state([1, 2, 3]);
const total = $derived.by(() => {
let total = 0;
for (const n of numbers) {
total += n;
}
return total;
});
</script>
<button onclick={() => numbers.push(numbers.length + 1)}>
{numbers.join(' + ')} = {total}
</button>
Frontend Engineer
Arc is dead, it's time to make the switch to a browser that gives a sh*t about it's users.
Feb 21, 2025
finalchat The last chat you'll ever need
The modern registry toolchain
Extra components for shadcn-svelte
A Svelte port of shadcn/ui
Want to work with me? Great! Reach out below!
Contact Me