Svelte 5 is out! It's packed with new features and I've heard good reviews so far, but I hadn’t had a chance to dive in until now. I've spent the last year building a SvelteKit web app using Svelte 4 (racquetrivals.com, come play). For comparison, the app currently has 9 pages and ~15 other components.
In this post, I'll share the steps I took to upgrade to Svelte 5 and what I learned along the way. Here is the official migration guide for all the details on the upgrade.
Steps
1. Create a New Git Branch
git switch -c svelte-5-migration
2. Use the Svelte 5 Migration Script
This script prompts you to choose which directories to migrate, automatically converts Svelte components in those directories to the Svelte 5 syntax, and updates the core dependencies in your package.json
.
npx sv migrate svelte-5
At first, the script didn't execute correctly and warned me that I had invalid HTML in my project. Apparently, wrapping an <a>
tag around a <tr>
tag isn't a valid way to add a hyperlink to a table row. 🤔 I thought this was interesting as it always worked and I hadn't been warned before, but I went ahead, refactored, and committed the changes to clear my working directory. After that, the script ran successfully.
Note - You can also try migrating one component at a time in VS Code from the command palette Cmd+Shift+P
with the command Migrate Component to Svelte 5 Syntax
to get more control. I couldn't get this to work for me and went with the script, but it's worth looking into.
3. Update Dependencies
npm install
It's necessary to run npm install
afterwards to update dependencies based on your new package.json
to ensure everything still plays nice together. I got some errors saying I needed to update other packages so I did, but that didn't resolve the errors right away. I deleted my node_modules
directory and my package-lock.json
, then ran npm install
again so they could be built from scratch, and that fixed my issues.
4. Review Updated Components
After my components had updated, VS Code still thought the new Svelte 5 syntax was invalid for some reason. I restarted VS Code and the errors went away, so you may need to do the same.
Here is part of my ResetPassword.svelte
component script tag before the migration.
let email = ''
let showEmailValidation = false
let error = ''
let loading = false
let success = false
let buttonRef: HTMLButtonElement
$: disabled = loading || showEmailValidation
$: if (buttonRef && !showEmailValidation) {
buttonRef.disabled = false
buttonRef.focus()
}
And here it is after
let email = $state('')
let showEmailValidation = $state(false)
let error = $state('')
let loading = $state(false)
let success = $state(false)
let buttonRef: HTMLButtonElement = $state()
let disabled = $derived(loading || showEmailValidation)
run(() => {
if (buttonRef && !showEmailValidation) {
buttonRef.disabled = false
buttonRef.focus()
}
})
The new Svelte 5 rune syntax has been added so the code more explicitly shows our state with the $state()
rune, derived state with $derived()
, and effects with run
. The $:
syntax previously allowed for both derived state and side effects to run reactively when their dependencies changed, but if the migration script couldn't reliably migrate the statement to $derived()
, it replaced it with run
instead.
This is a stopgap solution that mimics the behavior of $:
as it will run once on the server. run
may look new, but it's technically deprecated as it is meant to be manually refactored during the migration. $:
usages can usually replaced with one of the following
$derived()
- for defining a new reactive state from other state$effect()
- for running side effects based on the effect's dependencies on the client onlyonMount()
- for running code once when your component has been mounted on the client
Any state that depends on other state should use $derived
so it can be rendered on the server. You should try to avoid using $effect()
to update component state as it will look different on the client and server. Using effects to update state is a common pitfall in React as well, as it is often possible to define derived state instead. For code that just needs to run one time once it's on the client, prefer to use onMount
over $effect()
.
Rich Harris, the creator of Svelte, even said the $effect()
rune was named that way to discourage people from using it, as many developers are aware of the pains of useEffect
from React (see this post). Effects in component frameworks are necessary but can be notoriously tricky to debug, so it's important not to overdo it.
5. Refactor run
Statements
I started going through each file and refactoring the run
statements. Thinking in terms of the new runes made it easier to reason about what should use $derived()
and what I really need $effect()
for. Here is an example.
I am loading whether a tournament’s leaderboard is toggled on from the server first, then I am transitioning control over to the client-side Svelte stores $isAuth
and $isLeaderboard
once the client is loaded.
// original
let combinedIsLeaderboard = serverIsLeaderboard
$: combinedIsLeaderboard = $isAuth && $isLeaderboard
After the migration script, this is a little more clear that we are instantiating a stateful value with server data and then updating it client-side.
// migrated
let combinedIsLeaderboard = $state(serverIsLeaderboard)
run(() => {
combinedIsLeaderboard = $isAuth && $isLeaderboard
})
I could easily replace run
with $effect()
and call it a day, but this code starts to smell. After the migration, it's pretty clear that I'd be using $effect()
to update component state, which I just mentioned is an anti-pattern. It's possible to derive the state directly from server data and client state, so $derived()
is best choice.
We can use the Svelte browser
variable to determine whether to use the client or server variables during the page load. It would also be possible to use onMount
to pass control to the client, this is just my preference.
// refactored
let combinedIsLeaderboard =
$derived(browser ? $isAuth && $isLeaderboard : serverIsLeaderboard)
The result with $derived()
is much more clear than the original code.
6. Resolve Miscellaneous Errors
This section is a bit of a tangent, but the Svelte VS Code extension and TypeScript seem to have gotten smarter with Svelte 5. Runes provide more clarity in the code, and they also provide more clarity in error messages.
$state()
The migration did a perfect job converting the original state assignments to the new $state()
syntax. However, if I never initialized the state in the first place, it's now pretty obvious that those errors are my fault.
// original
let buttonRef: HTMLButtonElement
// migrated
let buttonRef: HTMLButtonElement = $state()
Now that we're required to use $state()
, TypeScript read my initial state as undefined
and complained since buttonRef
is expected to be a HTMLButtonElement
. This is a good thing because this issue existed before the migration, it just wasn't obvious to me (or TypeScript, for that matter).
// refactored
let buttonRef: HTMLButtonElement | null = $state(null)
Now TypeScript is happy, and we just need to explicitly check if the buttonRef
exists later in the code.
bind
Here’s another hidden issue that already existed in my code. The bind
keyword is used to sync data in both directions between a child component and state on a parent. VS Code now pointed out that I was incorrectly using it when passing data one way from parent to child, where it isn't necessary.
// incorrect
<FormError bind:error /> // shorthand for bind:error={error}
// correct
<FormError {error} /> // shorthand for error={error}
Be sure to check your console for warnings as well. I got a warning on one component that I was "Binding to a non-reactive property". Once again, it wasn't lying.
// incorrect
<input type="hidden" name="slotId" bind:value={slot.id} />
slot.id
is a property on a prop being passed into my component, not a stateful value that needs to be bound to the child input's value. Just passing it one way to the input is sufficient.
// correct
<input type="hidden" name="slotId" value={slot.id} />
These new errors were confusing at first but helped me understand Svelte better. I digress.
7. Verify Migration Changes
Slots → Snippets
Slots have now been replaced with a new concept called snippets. I didn't need to make any manual changes for this update but it's worth checking out on your end.
Snippets are reusable pieces of code inside your component. They can be passed to other components as props. Any content inside the component tags that is not a snippet declaration implicitly becomes part of that component's children
prop. If you're familiar with React, it's like when a React component written as the child of another component becomes part of the parent component's children
prop.
Here's an example from the Svelte docs.
// App.svelte
<Button>click me</Button>
The string click me
becomes a snippet that is passed to the Button
component as the children
prop. The new @render
tag can then render snippets in the markup.
// Button.svelte
<script>
let { children } = $props()
</script>
<!-- result will be <button>click me</button> -->
<button>{@render children()}</button>
So instead of content being passed into a slot, it's defined as a snippet, passed as the children
prop, and rendered with @render
.
Also note the new $props()
rune for defining component props more explicitly. I never liked the old export
syntax, because we're importing these props into the component right??
Anyways...
This is a simplified version of what happened in my root +layout.svelte
component.
// original
<script>
import type { RootLayoutData } from '$lib/types'
export let data: RootLayoutData
// ...
</script>
<slot />
<footer />
// migrated
<script>
// ...
import type { RootLayoutData } from '$lib/types'
import type { Snippet } from 'svelte'
interface Props {
data: RootLayoutData
children?: Snippet
}
let { data, children }: Props = $props()
// ...
</script>
{@render children?.()}
<footer />
More complicated? Maybe. But snippets seem pretty useful so I like them so far. Be sure to check your components for other changes made by the migration.
Unit Testing
Writing unit tests for this project was a slog since I neglected them at first, but this was a moment I was thankful to have them. After the migration refactored almost every component automatically, being able to run npm test
and find issues quickly was very handy. My unit tests pointed me to the problematic components and gave me confidence that everything worked once all the tests passed.
I also ran the app locally to see it with my own eyes. Nothing changed, and it was beautiful.
8. Merge Branch Back Into Main
git switch main
git merge svelte-5-migration
...or create a pull request if that's what your team does. You're done!
Takeaways
I didn't know what to think about runes at first but now I'm a big fan. The $:
was cool but maybe a little too cool. 😎 Replacing $:
with $state()
, $derived()
, and $effect()
makes it easier to use the right tool for the job. I would highly recommend upgrading when you have the chance.
Component frameworks are meant to hide complexity so developers can focus on the custom app logic. Determining how much to abstract away is both an art and a science that framework authors think about. More abstraction usually means less complexity for the developer but also less control.
Frameworks exist on a spectrum of abstraction and in my opinion Svelte 4 was pretty high on the list, at least compared to React or Angular. Features like $:
and the export
props syntax now feel too abstracted and "magical" as they're doing a lot under the hood.
Svelte 5 turns the abstraction dial down and adds more clarity to the syntax, which feels a lot more natural to me. This update is a big improvement on my favorite framework and I can't wait to dig into it more.