Modern Web · Svelte

Svelte 5 Runes — A Complete Guide

How Svelte 5's runes — $state, $derived, and $effect — replace Svelte 4's implicit let-based reactivity and $: statements with an explicit model that works the same inside and outside components.

John Kihiu12 min read

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.

SVELTE · $STATE BASICS
<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.

SVELTE · $DERIVED
<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>
$derived values are read-only

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.

SVELTE · $EFFECT WITH CLEANUP
<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>
$effect is not for deriving values

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.

JAVASCRIPT · SHARED REACTIVE STATE MODULE (counter.svelte.js)
// 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.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.