Svelte 4's reactivity worked by rewriting your code at compile time: a bare let count = 0 at the top of a component became reactive because the compiler could see it was a top-level component variable, and $: doubled = count * 2 was a magic label the compiler turned into a reactive statement. It worked well inside a `.svelte` file, and fell apart the moment you tried to extract that logic into a plain `.js` file — reactivity was tied to the compiler recognizing a specific syntactic position, not to any real primitive you could pass around. Svelte 5 replaces all of it with runes: explicit function-like markers — $state, $derived, $effect — that create real reactive values you can use anywhere, not just in component top-level scope.
$state replaces implicit let reactivity
$state() wraps a value in Svelte's reactivity system explicitly. In Svelte 4, a component-level let was reactive by virtue of being a component-level let — the compiler special-cased that position. In Svelte 5, you say so directly, and the same variable stays reactive whether it's declared in a component, a `.svelte.js` module, or returned from a function.
<script>
let count = $state(0);
function increment() {
count += 1; // still just normal assignment
}
</script>
<button onclick={increment}>
clicked {count} {count === 1 ? 'time' : 'times'}
</button>
$derived replaces reactive statements
Svelte 4's $: doubled = count * 2 reran whenever any variable the compiler detected inside the expression changed — but the dependency tracking was based on static analysis of the statement, which broke down with anything conditional or indirect. $derived() is a value computed from other reactive state, re-evaluated automatically when its actual runtime dependencies change, tracked the same way $effect tracks dependencies: by seeing what reactive values were read during execution.
<script>
let count = $state(0);
let doubled = $derived(count * 2);
// $derived.by() for computations too complex for a single expression
let summary = $derived.by(() => {
if (count === 0) return 'nothing yet';
return `count is ${count}, doubled is ${doubled}`;
});
</script>
<p>{summary}</p>
Trying to assign to a $derived value throws at runtime. If you need a value that's sometimes computed and sometimes overridden manually, that's a sign you want plain $state plus an $effect to keep it in sync, not $derived.
$effect replaces lifecycle and reactive side effects
$effect() runs a function whenever any reactive value it reads changes, and runs once after the component mounts. It's the direct replacement for the reactive $: statements people used for side effects (as opposed to derived values) and largely replaces `onMount` for anything that needs to react to state changes over the component's lifetime, not just run once at mount.
<script>
let query = $state('');
let results = $state([]);
$effect(() => {
if (!query) {
results = [];
return;
}
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => { results = data; })
.catch(() => {});
// Returned function runs before the next effect run, or on unmount —
// this is how you cancel a stale in-flight request.
return () => controller.abort();
});
</script>
If you're writing `$effect(() => { doubled = count * 2 })` to keep one piece of state in sync with another, that's exactly what $derived is for. Reaching for $effect to compute a value instead of running a side effect (fetching, logging, subscribing to something external) is the most common misuse of runes coming from Svelte 4's `$:` habits, where the same syntax covered both cases.
Runes work outside components too
Because runes are compiler-recognized function calls rather than syntax tied to a component's top-level scope, you can put them in a plain `.svelte.js` (or `.svelte.ts`) module and export reactive state directly — something Svelte 4's implicit reactivity couldn't do without wrapping everything in stores.
// counter.svelte.js — the .svelte.js extension is what tells the
// compiler to process runes in this file.
export function createCounter(initial = 0) {
let count = $state(initial);
return {
get value() { return count; },
increment: () => count++,
reset: () => { count = initial; },
};
}
Wrapping up
The through-line across all three runes is the same: Svelte 5 traded implicit, position-dependent reactivity for explicit primitives that behave consistently wherever they're used. $state replaces the special meaning of top-level let, $derived replaces reactive statements used for computed values, and $effect replaces reactive statements and lifecycle hooks used for side effects. The migration cost is real if you have a large Svelte 4 codebase, but the resulting model is easier to reason about precisely because it stops depending on where in a file a variable happens to be declared.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.