You’ve been writing React for years. Hooks feel natural — you reach for useState, wire up a useEffect, throw a useMemo on something that’s getting slow, and ship. Then you look at Svelte 5’s runes and think: "OK this looks vaguely similar, I’ll just map them one-to-one and call it a day."
That mapping will get you halfway. The other half — the part where your components stop behaving like React components and start behaving like Svelte components — requires unlearning something fundamental about how React makes things reactive.
This article is that second half. It’s not a syntax cheatsheet. It’s a model comparison.
Official Svelte 5 docs: https://svelte.dev/docs/svelte/what-are-runes
The Core Difference Nobody Explains Well Enough
React’s reactivity model is pull-based and snapshot-driven. When you call setState, React schedules a re-render, and your entire component function runs again. The "state" you read inside the function body is a snapshot frozen at that render’s moment in time. This is why stale closures exist, why dependency arrays exist, and why useCallback/useMemo exist — they’re all workarounds for the fact that your component function is a closure that captures values, not references.
Svelte 5’s rune model is push-based and signal-driven. $state creates a reactive signal. When you read it inside a $derived or $effect, Svelte registers that dependency automatically at runtime. When the signal changes, only the things that actually read it update — no full re-run, no virtual DOM diffing, no dependency arrays to maintain by hand.
The short version: React reruns your whole function and diffs the output. Svelte runs the minimum necessary code surgically.
That distinction explains nearly every gotcha you’ll hit in migration.
useState → $state
React:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
Svelte 5:
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
Clicked {count} times
</button>
Looks simple. The trap: in React, count is a plain number and setCount is how you ask React to schedule a re-render with a new snapshot. In Svelte, count is a signal proxy — reading it registers a dependency, writing it triggers updates.
This means you can do count++ directly. You don’t call a setter. There’s no async batching to think about. The assignment is the update.
Object state is where this really diverges:
// React: you must spread to trigger update
const [user, setUser] = useState({ name: 'Alex', age: 30 });
setUser(prev => ({ ...prev, age: 31 })); // can't just mutate
<!-- Svelte 5: just mutate -->
<script>
let user = $state({ name: 'Alex', age: 30 });
</script>
<button onclick={() => user.age++}>Birthday</button>
Svelte’s $state uses a Proxy under the hood for objects and arrays, so nested mutations are tracked automatically. No spreading, no immer, no setUser({ ...user, nested: { ...user.nested, value: x } }) pyramids.
Gotcha: $state in class fields
Runes shine in class-based state stores:
// counter.svelte.ts
class Counter {
count = $state(0);
increment() {
this.count++;
}
}
export const counter = new Counter();
This works. The $state rune is not tied to a component lifecycle the way React hooks are. You can use it in .svelte.ts files, in singletons, in class instances. React’s useState cannot leave component scope — it’s a hook and hooks are component-only.
useMemo → $derived
React:
const doubled = useMemo(() => count * 2, [count]);
Svelte 5:
<script>
let count = $state(0);
const doubled = $derived(count * 2);
</script>
No dependency array. Svelte tracks which signals were read during the expression evaluation and subscribes to exactly those. Change something the expression doesn’t read, and it won’t recompute.
For multi-step derivations, use $derived.by:
<script>
let items = $state([1, 2, 3, 4, 5]);
const stats = $derived.by(() => {
const sum = items.reduce((a, b) => a + b, 0);
const avg = sum / items.length;
return { sum, avg };
});
</script>
<p>Sum: {stats.sum}, Avg: {stats.avg}</p>
Gotcha: $derived is not lazy like useMemo
React’s useMemo only recomputes when dependencies change between renders. Svelte’s $derived is live — it recomputes synchronously when any of its dependencies change. For cheap computations, this doesn’t matter. For expensive ones (sorting 100k items), you’ll want to think about whether $derived is the right place, or whether you should gate updates upstream.
Also: you cannot assign to a $derived value. It’s read-only. Trying to do doubled = 10 is a compile-time error. Good — it prevents a whole class of bugs React devs sometimes introduce by mutating memoized objects.
useEffect → $effect
This is the biggest mental model jump. React’s useEffect is a synchronization mechanism — you use it to sync React state with the outside world (DOM, APIs, timers). The dependency array tells React when to re-run it.
// React: manual dependency contract
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // if you forget count here, you get a stale closure bug
<!-- Svelte 5: automatic tracking -->
<script>
let count = $state(0);
$effect(() => {
document.title = `Count: ${count}`;
// count is read here, so it's tracked automatically
});
</script>
Same outcome, but the dependency list is implicit. Whatever signals you read inside $effect are tracked. No lint rules needed to enforce correct deps. No // eslint-disable-next-line react-hooks/exhaustive-deps comments.
Cleanup works the same way — return a function:
<script>
let enabled = $state(false);
$effect(() => {
if (!enabled) return;
const id = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(id); // runs before re-execution or on destroy
});
</script>
Gotcha: $effect runs after DOM update, not synchronously
Like useEffect, $effect is asynchronous relative to the render cycle. If you need to read layout from the DOM immediately after an update, use $effect.pre — it runs before the DOM update, equivalent to React’s useLayoutEffect.
Gotcha: No $effect for derived state
The classic React mistake of computing derived values inside useEffect and setState-ing them:
// Bad React pattern
useEffect(() => {
setDoubled(count * 2); // causes an extra render
}, [count]);
Svelte devs inherit this bad instinct. Don’t do it. If the value is derivable, use $derived. $effect is for side effects — DOM manipulation, network calls, subscriptions. Not state computation.
useCallback → Nothing
React needs useCallback because function identity changes on every render, which causes unnecessary re-renders in child components that receive functions as props.
Svelte doesn’t re-render. There’s no virtual DOM reconciliation to trigger. Functions defined in <script> are defined once and referenced by identity. You don’t need useCallback at all — just write a normal function.
<script>
import Child from './Child.svelte';
let count = $state(0);
// This doesn't cause Child to "re-render" even though it's defined in script
function handleClick() {
count++;
}
</script>
<Child on:action={handleClick} />
If you find yourself reaching for an equivalent of useCallback, that’s a sign you’re still thinking in React’s re-render model.
useRef → Plain Variables or bind:this
React’s useRef serves two roles: stable mutable containers that survive re-renders, and DOM node references.
For DOM references in Svelte 5:
<script>
let inputEl = $state(); // or just: let inputEl;
function focusInput() {
inputEl.focus();
}
</script>
<input bind:this={inputEl} />
<button onclick={focusInput}>Focus</button>
For mutable containers that shouldn’t trigger re-renders: just use a plain let variable. Since Svelte doesn’t re-render the entire component function, a plain let is stable across reactive updates without needing a ref wrapper.
<script>
let renderCount = 0; // survives reactive updates, never causes them
let count = $state(0);
$effect(() => {
// reading count makes this effect reactive
renderCount++;
console.log(`Effect ran ${renderCount} times`);
});
</script>
useContext / createContext → Svelte Context
React:
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Child />
</ThemeContext.Provider>
);
}
function Child() {
const theme = useContext(ThemeContext);
}
Svelte 5:
<!-- App.svelte -->
<script>
import { setContext } from 'svelte';
setContext('theme', 'dark');
</script>
<!-- Child.svelte -->
<script>
import { getContext } from 'svelte';
const theme = getContext('theme');
</script>
For reactive context (where changing the value should propagate down), pass a $state object rather than a primitive:
<!-- App.svelte -->
<script>
import { setContext } from 'svelte';
const theme = $state({ mode: 'dark' });
setContext('theme', theme); // pass the reactive object, not the value
</script>
<button onclick={() => theme.mode = 'light'}>Switch</button>
Child components that read theme.mode from context will react automatically because they’re reading from the same reactive object.
Gotcha: getContext only works at component initialization
Just like useContext can only be called at the top level of a component (not inside loops or conditionals), getContext must be called during component initialization — synchronously in <script>, not inside $effect or event handlers.
$props vs React Props Destructuring
React:
function Button({ label, disabled = false, onClick }: ButtonProps) {
return <button disabled={disabled} onClick={onClick}>{label}</button>;
}
Svelte 5:
<script lang="ts">
interface Props {
label: string;
disabled?: boolean;
onclick?: () => void;
}
let { label, disabled = false, onclick }: Props = $props();
</script>
<button {disabled} {onclick}>{label}</button>
One significant difference: in Svelte 5, $props() returns a reactive object. The individual destructured values are live — if the parent passes a new value for disabled, your component sees it without any lifecycle hook or re-render.
For two-way binding, use $bindable:
<!-- Input.svelte -->
<script>
let { value = $bindable('') } = $props();
</script>
<input bind:value />
<!-- Parent.svelte -->
<script>
let text = $state('hello');
</script>
<Input bind:value={text} />
This pattern has no clean React equivalent without rolling your own controlled component + callback pair.
Custom Hooks → Rune Functions in .svelte.ts
React’s biggest superpower is custom hooks — composable, reusable stateful logic. Svelte 5 matches this with rune functions in .svelte.ts files:
// useWindowSize.svelte.ts
export function useWindowSize() {
let width = $state(window.innerWidth);
let height = $state(window.innerHeight);
$effect(() => {
function update() {
width = window.innerWidth;
height = window.innerHeight;
}
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
});
return {
get width() { return width; },
get height() { return height; }
};
}
<!-- Component.svelte -->
<script>
import { useWindowSize } from './useWindowSize.svelte.ts';
const { width, height } = useWindowSize();
</script>
<p>{width} × {height}</p>
The get accessor pattern is intentional — it ensures the returned object exposes reactive reads. If you just return { width, height }, you’d return the current primitive values at the time of the function call (frozen), not live reactive references. The getter forces signal reads to happen at access time inside the consumer.
This is the most common migration pitfall for React devs writing custom Svelte composables.
The $inspect Rune: Your Migration Debug Tool
Svelte 5 ships with a built-in debugging rune that’s invaluable during migration:
<script>
let count = $state(0);
$inspect(count); // logs to console whenever count changes
// Output: [init] 0
// After increment: [update] 1
</script>
It’s stripped in production builds. Use it liberally while you’re figuring out why something doesn’t update when you expect it to.
Production-Ready Patterns
Global reactive store without a library:
// stores/auth.svelte.ts
function createAuthStore() {
let user = $state(null);
let loading = $state(false);
return {
get user() { return user; },
get loading() { return loading; },
async login(email: string, password: string) {
loading = true;
user = await api.login(email, password);
loading = false;
},
logout() { user = null; }
};
}
export const auth = createAuthStore();
No Zustand, no Redux, no Pinia. A plain module export with runes is your store.
Conditional effects with explicit gating:
<script>
let { userId } = $props();
let data = $state(null);
$effect(() => {
// reads userId — will re-run when userId changes
if (!userId) return; // explicit guard, clean exit
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(d => { if (!cancelled) data = d; });
return () => { cancelled = true; }; // cleanup on userId change
});
</script>
This is cleaner than the React equivalent because there’s no risk of a stale userId inside the fetch callback — Svelte’s effect captured a reference to the signal, not a snapshot.
What You Don’t Need Anymore
After the migration, you can stop reaching for:
useCallback— no re-render problem to solveuseMemofor simple derivations —$derivedauto-tracks- Dependency arrays — everywhere
- Spread operator for state mutation — just mutate
React.memo()— components don’t re-render by default; they update surgicallyflushSync— DOM updates in Svelte are synchronous within a tick
The cognitive overhead you shed is real. Svelte 5’s model has fewer escape hatches because it needs fewer escapes.
The One Thing React Still Does Better
Ecosystem. The React ecosystem is enormous, and a lot of headless UI libraries, animation libraries, and third-party integrations assume React. Svelte’s ecosystem is growing fast, but if you’re migrating a large React app that depends on react-query, Radix UI, or Framer Motion equivalents, budget time for finding or building Svelte alternatives.
For greenfield projects or apps where you control the full stack, Svelte 5’s rune model is cleaner, faster to write, and produces smaller bundles. For apps heavily invested in React’s ecosystem, the migration cost is real and worth measuring before committing.
The mental model shift takes a day or two to internalize. The productivity gain, once it clicks, tends to stick.