
@ hodlbod
2025-02-18 20:30:32
For the last couple of weeks, I've been dealing with the fallout of upgrading a web application to Svelte 5. Complaints about framework churn and migration annoyances aside, I've run into some interesting issues with the migration. So far, I haven't seen many other people register the same issues, so I thought it might be constructive for me to articulate them myself.
I'll try not to complain too much in this post, since I'm grateful for the many years of Svelte 3/4 I've enjoyed. But I don't think I'll be choosing Svelte for any new projects going forward. I hope my reflections here will be useful to others as well.
If you're interested in reproductions for the issues I mention here, you can find them below.
- [Can't save state to indexeddb](https://github.com/sveltejs/svelte/issues/15327)
- [Component unmount results in undefined variables in closures](https://github.com/sveltejs/svelte/issues/15325)
# The Need for Speed
To start with, let me just quickly acknowledge what the Svelte team is trying to do. It seems like most of the substantial changes in version 5 are built around "deep reactivity", which allows for more granular reactivity, leading to better performance. Performance is good, and the Svelte team has always excelled at reconciling performance with DX.
In previous versions of Svelte, the main way this was achieved was with the Svelte compiler. There were many ancillary techniques involved in improving performance, but having a framework compile step gave the Svelte team a lot of leeway for rearranging things under the hood without making developers learn new concepts. This is what made Svelte so original in the beginning.
At the same time, it resulted in an even more opaque framework than usual, making it harder for developers to debug more complex issues. To make matters worse, the compiler had bugs, resulting in errors which could only be fixed by blindly refactoring the problem component. This happened to me personally at least half a dozen times, and is what ultimately pushed me to migrate to Svelte 5.
Nevertheless, I always felt it was an acceptable trade-off for speed and productivity. Sure, sometimes I had to delete my project and port it to a fresh repository every so often, but the framework was truly a pleasure to use.
# Svelte is not Javascript
Svelte 5 doubled down on this tradeoff — which makes sense, because it's what sets the framework apart. The difference this time is that the abstraction/performance tradeoff did not stay in compiler land, but intruded into runtime in two important ways:
- The use of proxies to support deep reactivity
- Implicit component lifecycle state
Both of these changes improved performance _and_ made the API for developers look slicker. What's not to like? Unfortunately, both of these features are classic examples of a [leaky abstraction](https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/), and ultimately make things _more_ complex for developers, not less.
## Proxies are not objects
The use of proxies seems to have allowed the Svelte team to squeeze a little more performance out of the framework, without asking developers to do any extra work. Threading state through multiple levels of components without provoking unnecessary re-renders in frameworks like React is an infamously difficult chore.
Svelte's compiler avoided some of the pitfalls associated with virtual DOM diffing solutions, but evidently there was still enough of a performance gain to be had to justify the introduction of proxies. The Svelte team also [seems to argue](https://svelte.dev/blog/runes) that their introduction represents an improvement in developer experience:
> we... can maximise both efficiency and ergonomics.
Here's the problem: Svelte 5 _looks_ simpler, but actually introduces _more_ abstractions.
Using proxies to monitor array methods (for example) is appealing because it allows developers to forget all the goofy heuristics involved with making sure state was reactive and just `push` to the array. I can't count how many times I've written `value = value` to trigger reactivity in svelte 4.
In Svelte 4, developers had to understand how the Svelte compiler worked. The compiler, being a leaky abstraction, forced its users to know that assignment was how you signaled reactivity. In svelte 5, developers can just "forget" about the compiler!
Except they can't. All the introduction of new abstractions really accomplishes is the introduction of more complex heuristics that developers have to keep in their heads in order to get the compiler to act the way they want it to.
In fact, this is why after years of using Svelte, I found myself using Svelte stores more and more often, and reactive declarations less. The reason being that Svelte stores are _just javascript_. Calling `update` on a store is _simple_, and being able to reference them with a `$` was just a nice bonus — nothing to remember, and if I mess up the compiler yells at me.
Proxies introduce a similar problem to reactive declarations, which is that they look like one thing but act like another on the edges.
When I started using Svelte 5, everything worked great — until [I tried to save a proxy to indexeddb](https://github.com/sveltejs/svelte/issues/15327), at which point I got a `DataCloneError`. To make matters worse, it's impossible to reliably tell if something is a `Proxy` without `try/catch`ing a structured clone, which is a performance-intensive operation.
This forces the developer to remember what is and what isn't a Proxy, calling `$state.snapshot` every time they pass a proxy to a context that doesn't expect or know about them. This obviates all the nice abstractions they gave us in the first place.
## Components are not functions
The reason virtual DOM took off way back in 2013 was the ability to model your application as composed functions, each of which takes data and spits out HTML. Svelte retained this paradigm, using a compiler to sidestep the inefficiencies of virtual DOM and the complexities of lifecycle methods.
In Svelte 5, component lifecycles are back, react-hooks style.
In React, hooks are an abstraction that allows developers to avoid writing all the stateful code associated with component lifecycle methods. Modern React tutorials universally recommend using hooks instead, which rely on the framework invisibly synchronizing state with the render tree.
While this does result in cleaner code, it also requires developers to tread carefully to avoid breaking the assumptions surrounding hooks. Just try accessing state in a `setTimeout` and you'll see what I mean.
Svelte 4 had a few gotchas like this — for example, async code that interacts with a component's DOM elements has to keep track of whether the component is unmounted. This is pretty similar to the kind of pattern you'd see in old React components that relied on lifecycle methods.
It seems to me that Svelte 5 has gone the React 16 route by adding implicit state related to component lifecycles in order to coordinate state changes and effects.
For example, here is an excerpt from the documentation for [$effect](https://svelte.dev/docs/svelte/$effect):
> You can place $effect anywhere, not just at the top level of a component, as long as it is called during component initialization (or while a parent effect is active). It is then tied to the lifecycle of the component (or parent effect) and will therefore destroy itself when the component unmounts (or the parent effect is destroyed).
That's very complex! In order to use `$effect`... effectively (sorry), developers have to understand how state changes are tracked. The [documentation for component lifecycles](https://svelte.dev/docs/svelte/lifecycle-hooks) claims:
> In Svelte 5, the component lifecycle consists of only two parts: Its creation and its destruction. Everything in-between — when certain state is updated — is not related to the component as a whole; only the parts that need to react to the state change are notified. This is because under the hood the smallest unit of change is actually not a component, it’s the (render) effects that the component sets up upon component initialization. Consequently, there’s no such thing as a “before update”/"after update” hook.
But then goes on to introduce the idea of `tick` in conjunction with `$effect.pre`. This section explains that "`tick` returns a promise that resolves once any pending state changes have been applied, or in the next microtask if there are none."
I'm sure there's some mental model that justifies this, but I don't think the claim that a component's lifecycle is only comprised of mount/unmount is really helpful when an addendum about state changes has to come right afterward.
The place where this really bit me, and which is the motivation for this blog post, is when state gets coupled to a component's lifecycle, even when the state is passed to another function that doesn't know anything about svelte.
In my application, I manage modal dialogs by storing the component I want to render alongside its props in a store and rendering it in the `layout.svelte` of my application. This store is also synchronized with browser history so that the back button works to close them. Sometimes, it's useful to pass a callback to one of these modals, binding caller-specific functionality to the child component:
```javascript
const {value} = $props()
const callback = () => console.log(value)
const openModal = () => pushModal(MyModal, {callback})
```
This is a fundamental pattern in javascript. Passing a callback is just one of those things you do.
Unfortunately, if the above code lives in a modal dialog itself, the caller component gets unmounted before the callback gets called. In Svelte 4, this worked fine, but in Svelte 5 `value` gets updated to `undefined` when the component gets unmounted. [Here's a minimal reproduction](https://github.com/sveltejs/svelte/issues/15325).
This is only one example, but it seems clear to me that _any_ prop that is closed over by a callback function that lives longer than its component will be undefined when I want to use it — with no reassignment existing in lexical scope. It seems that the [reason this happens](https://github.com/sveltejs/svelte/issues/14707) is that the props "belong" to the parent component, and are accessed via getters so that the parent can revoke access when it unmounts.
I don't know why this is necessary, but I assume there's a good engineering reason for it. The problem is, this just isn't how javascript works. Svelte is essentially attempting to re-invent garbage collection around component lifecycles, which breaks the assumption every javascript developer has that variables don't simply disappear without an explicit reassignment. It should be safe to pass stuff around and let the garbage collector do its job.
# Conclusion
Easy things are nice, but as Rich Hickey says, [easy things are not always simple](https://www.infoq.com/presentations/Simple-Made-Easy/). And like Joel Spolsky, I don't like being surprised. Svelte has always been full of magic, but with the latest release I think the cognitive overhead of reciting incantations has finally outweighed the power it confers.
My point in this post is not to dunk on the Svelte team. I know lots of people like Svelte 5 (and react hooks). The point I'm trying to make is that there is a tradeoff between doing things on the user's behalf, and giving the user agency. Good software is built on understanding, not cleverness.
I also think this is an important lesson to remember as AI-assisted coding becomes increasingly popular. Don't choose tools that alienate you from your work. Choose tools that leverage the wisdom you've already accumulated, and which help you to cultivate a deeper understanding of the discipline.
Thank you to Rich Harris and team for many years of pleasant development. I hope that (if you read this) it's not _so_ full of inaccuracies as to be unhelpful as user feedback.