Introduction

Svelte 5 broke your components. On purpose. The old reactive declarations ($:) are gone and the replacement is better but different enough that you will need to touch every file.

If you have a Svelte 4 codebase, this is what you need to know: the migration is incremental, the new APIs are $state, $derived, $effect, and $props, and the compiler still does the heavy lifting at build time. But the implicit magic that made Svelte feel like "just JavaScript" -- while secretly being nothing of the sort -- is gone. What you get instead is explicit, portable reactivity that works in plain .js files, not just .svelte components.

More syntax upfront. Fewer surprise bugs. Fair trade.

What Changed in Svelte 5

Svelte 4 reactivity was compiler-driven and file-scoped. The problems were real:

Runes replace all of it. $-prefixed function-like syntax that the compiler still compiles away, but now the API is explicit and works outside .svelte files.

$state: Declaring Reactive Variables

Counter.svelte
<script>let count = $state(0);
 functionincrement() {
 count++;
 }
 functiondecrement() {
 count--;
 }
</script><div><buttononclick={decrement}>-</button><span>{count}</span><buttononclick={increment}>+</button></div>

Yes, more boilerplate than the old let count = 0. But $state does something the old syntax never could: deep reactivity. Objects and arrays track their own mutations:

DeepReactivity.svelte
<script>let todos = $state([
 { text: 'Learn runes', done: false },
 { text: 'Build something cool', done: false }
 ]);
 functionaddTodo(text) {
 // This just works now. No more todos = todos trick.
 todos.push({ text, done: false });
 }
 functiontoggleTodo(index) {
 // Deep mutation is tracked automatically.
 todos[index].done = !todos[index].done;
 }
</script>

Proxy under the hood. Intercepts mutations, schedules updates. The entire category of "forgot to do items = items" bugs is gone.

$state.raw() skips the proxy. Large immutable datasets where you replace the whole value instead of mutating:

RawState.svelte
<script>// Only reassignment triggers updates, not deep mutations.let largeDataset = $state.raw(fetchInitialData());
 async functionrefresh() {
 // This triggers a re-render because we are reassigning.
 largeDataset = awaitfetchFreshData();
 }
</script>

$state by default. $state.raw only when profiling says so.

$derived: Computed Values

Computed values. Same idea as before, different syntax:

DerivedExample.svelte
<script>let items = $state([
 { name: 'Apples', price: 2.50, quantity: 3 },
 { name: 'Bread', price: 4.00, quantity: 1 },
 { name: 'Milk', price: 3.25, quantity: 2 }
 ]);
 let totalPrice = $derived(
 items.reduce((sum, item) => sum + item.price * item.quantity, 0)
 );
 let itemCount = $derived(
 items.reduce((sum, item) => sum + item.quantity, 0)
 );
 let formattedTotal = $derived(
 `$${totalPrice.toFixed(2)}`
 );
</script><p>You have {itemCount} items in your cart.</p><p>Total: {formattedTotal}</p>

No dependency array. Svelte tracks what you read and recalculates when it changes.

$derived.by() for multi-statement computations:

DerivedBy.svelte
<script>let searchQuery = $state('');
 let selectedCategory = $state('all');
 let products = $state([/* ... product data ... */]);
 let filteredProducts = $derived.by(() => {
 let result = products;
 if (selectedCategory !== 'all') {
 result = result.filter(p => p.category === selectedCategory);
 }
 if (searchQuery.trim()) {
 const query = searchQuery.toLowerCase();
 result = result.filter(p =>
 p.name.toLowerCase().includes(query)
 );
 }
 return result;
 });
</script>

$derived for one-liners, $derived.by for everything else. Simple things stay simple.

Derived values are read-only. Assign to one and the compiler yells. Stricter than Vue's writable computed refs. But if you need a value that is both computed and manually overridable, your state model is wrong.

$effect: Side Effects Done Right

Effects exist because some things are not computations. Logging. DOM manipulation. API calls. localStorage writes. Svelte 4 jammed these into $: alongside computed values, and the result was that nobody could tell which $: blocks were pure and which had side effects. Svelte 5 draws the line: $derived computes values, $effect does things.

An effect runs when its dependencies change. It does not return a value. You do not declare dependencies -- Svelte tracks what you read and re-runs when those values change. Return a function for cleanup: Svelte calls it before re-running and when the component is destroyed. Effects run after the DOM updates, so reading layout information is safe.

EffectExample.svelte
<script>let theme = $state('light');
 let fontSize = $state(16);
 // This effect runs whenever theme or fontSize changes.$effect(() => {
 document.documentElement.setAttribute('data-theme', theme);
 document.documentElement.style.fontSize = `${fontSize}px`;
 // Cleanup function runs before the next execution// and when the component is destroyed.return () => {
 console.log('Cleaning up previous theme/font settings');
 };
 });
 // You can also use $effect for subscriptions.$effect(() => {
 const handler = (e) => console.log('Key pressed:', e.key);
 window.addEventListener('keydown', handler);
 return () => {
 window.removeEventListener('keydown', handler);
 };
 });
</script>

$effect.pre() runs before the DOM update. Preserving scroll position before a list re-renders. Not common.

Reading state and setting other state inside an $effect? You almost certainly want $derived. Effects synchronize with the outside world -- DOM, localStorage, analytics. Not internal data flow. This is the most common migration mistake.

$props and Component Communication

export let is dead. Good. export already means something in JavaScript and hijacking it for props was always awkward. $props uses standard destructuring:

UserCard.svelte
<script>// Destructure props with defaults.let {
 name,
 email,
 role = 'Member',
 avatar = null,
 onEdit,
 ...rest
 } = $props();
 let isEditing = $state(false);
 functionhandleSave() {
 isEditing = false;
 onEdit?.({ name, email, role });
 }
</script><div class="user-card"{...rest}>
 {#if avatar}
 <img src={avatar} alt={name}/>
 {/if}
 <h3>{name}</h3><p>{email}</p><span class="badge">{role}</span></div>

...rest forwards remaining props. Essential for wrapper components. Everything a component accepts is visible in one destructuring.

And createEventDispatcher is gone. That API -- import a factory, call it, dispatch named string events -- was the most awkward part of Svelte 4. Now callbacks are just props. React and Vue had this right all along.

TypeScript:

TypedComponent.svelte (TypeScript)
<script lang="ts">interfaceProps {
 title: string;
 count: number;
 variant?: 'primary' | 'secondary';
 onClick?: () => void;
 }
 let {
 title,
 count,
 variant = 'primary',
 onClick
 }: Props = $props();
</script>

Type safety, defaults, documentation. One place. No more scanning scattered export let declarations.

Building a Complete Component

All four runes together. Todo list with filtering and localStorage persistence:

TodoApp.svelte
<script>// Props: accepts a title and optional initial todos.let { title = 'My Todos', initialTodos = [] } = $props();
 // Reactive statelet todos = $state(initialTodos);
 let newTodoText = $state('');
 let filter = $state('all'); // 'all' | 'active' | 'completed'// Derived valueslet filteredTodos = $derived.by(() => {
 switch (filter) {
 case'active':
 return todos.filter(t => !t.done);
 case'completed':
 return todos.filter(t => t.done);
 default:
 return todos;
 }
 });
 let activeCount = $derived(
 todos.filter(t => !t.done).length
 );
 let completedCount = $derived(
 todos.filter(t => t.done).length
 );
 let allDone = $derived(
 todos.length >0 && activeCount === 0
 );
 // Side effect: persist to localStorage whenever todos change.$effect(() => {
 // $state.snapshot() gives you a plain (non-proxy) copy// that is safe to serialize.
 localStorage.setItem(
 'svelte-todos',
 JSON.stringify($state.snapshot(todos))
 );
 });
 // Side effect: update the document title.$effect(() => {
 document.title = activeCount >0
 ? `(${activeCount}) ${title}`
 : title;
 });
 // Event handlersfunctionaddTodo() {
 const text = newTodoText.trim();
 if (!text) return;
 todos.push({
 id: crypto.randomUUID(),
 text: text,
 done: false,
 createdAt: newDate()
 });
 newTodoText = '';
 }
 functionremoveTodo(id) {
 const index = todos.findIndex(t => t.id === id);
 if (index !== -1) todos.splice(index, 1);
 }
 functionclearCompleted() {
 const active = todos.filter(t => !t.done);
 todos.length = 0;
 todos.push(...active);
 }
</script><div class="todo-app"><h2>{title}</h2>
 {#if allDone}
 <p class="celebration">All done! Nice work.</p>
 {/if}
 <formonsubmit={(e) => { e.preventDefault(); addTodo(); }}><inputbind:value={newTodoText}
 placeholder="What needs to be done?"/><button type="submit">Add</button></form><nav class="filters"><button class:active={filter === 'all'}onclick={() => filter = 'all'}>
 All ({todos.length})
 </button><button class:active={filter === 'active'}onclick={() => filter = 'active'}>
 Active ({activeCount})
 </button><button class:active={filter === 'completed'}onclick={() => filter = 'completed'}>
 Completed ({completedCount})
 </button></nav><ul>
 {#each filteredTodos as todo (todo.id)}
 <li class:done={todo.done}><input
 type="checkbox"bind:checked={todo.done}/><span>{todo.text}</span><buttononclick={() => removeTodo(todo.id)}>
 Delete
 </button></li>
 {/each}
 </ul>
 {#if completedCount > 0}
 <button class="clear-btn"onclick={clearCompleted}>
 Clear completed ({completedCount})
 </button>
 {/if}
</div>

Data flow reads top to bottom. $props takes inputs. $state holds mutable data -- deep reactivity means todos.push() and toggling todo.done just work. $derived computes filtered lists and counts. $effect handles the two side effects.

$state.snapshot() before JSON.stringify. The proxy is not serializable. Easy to forget, hard to debug.

Migrating from Svelte 4

Compatibility mode. Old syntax works alongside runes. Migrate one component at a time.

State: let count = 0 becomes let count = $state(0).

Computed values: $: doubled = count * 2 becomes let doubled = $derived(count * 2). Had side effects? $effect instead.

Side effects: $: console.log(count) becomes $effect(() => { console.log(count) }).

Props: export let name becomes let { name } = $props().

Events: createEventDispatcher is gone. Callback props.

npx sv migrate svelte-5 handles the mechanical conversions. It will not decide $derived vs $effect for you -- that is the actual work.

Start with leaf components. Buttons, form inputs, cards. Fast. The tricky ones are components with chains of $: blocks where you have to untangle what is a computation and what is a side effect. Budget more time for those.

Svelte stores still work. But $state and $derived in plain .svelte.js files make many of them unnecessary. You can replace store files with simpler exported state. Or leave them. No rush.

What I Learned and What I Would Change

The biggest practical gain is portability. Reactive logic in plain .svelte.js files. Shared state without stores. Deep reactivity kills the mutation bugs that plagued Svelte 4. Four runes cover almost everything.

New project? Runes from the start. Migrating? Leaf components first, reactive chains last. And test the $derived vs $effect split carefully -- getting it wrong creates infinite loops that are not obvious until production.

Svelte keeps getting closer to being React with better defaults. Whether that is progress or convergence depends on why you chose Svelte in the first place.

Nisha Agarwal

Nisha Agarwal

Frontend Developer & Design Systems Architect

Nisha is a frontend specialist and design system architect who creates stunning UIs with CSS and React. She writes about UI patterns and component architecture.