EmpireUI
Get Pro
← Blog9 min read#svelte 5#runes#$state

Svelte 5 Runes: $state, $derived, $effect and the New Mental Model

Svelte 5 runes replace the old reactive-label magic with explicit primitives. Here's how $state, $derived, and $effect actually work — and when each one bites you.

Glowing computer terminal showing modern JavaScript framework code

Why Svelte 5 Rewrote Reactivity from Scratch

Svelte 4's reactivity was clever. Assign to a variable, and the compiler figured out the rest. But that magic had limits — it only worked inside .svelte files, it broke in extracted logic, and once you needed reactive state in a plain .js helper, you were reaching for stores. That friction accumulated fast.

Svelte 5, released in late 2024, replaced the label-based system with *runes*: explicit compiler-recognized function calls that work anywhere. Same file, different file, doesn't matter. $state, $derived, $effect, $props — these look like functions but they're really compiler signals. The dollar prefix is the giveaway.

In practice, the shift feels bigger than it is on paper. You're not learning a new framework, you're learning a new mental model for *where* reactivity lives. And honestly, the new model is cleaner. Less "why isn't this reactive?" and more "I declared it reactive, so it is."

Worth noting: the old Svelte 4 syntax still works in Svelte 5 — it's not an overnight migration. But for new code and any shared logic you're extracting, you'll want runes from day one.

$state: Reactive Variables Done Explicitly

$state is your basic reactive primitive. It replaces the implicit let x = 5 reactivity from Svelte 4. Now you say what you mean:

<script>
  let count = $state(0);
  let name = $state('Alice');
</script>

<button onclick={() => count++}>
  Clicked {count} times
</button>

That count++ works exactly as you'd expect — Svelte tracks the dependency and re-renders anything that reads count. No special syntax, no $: label. The mutation is the trigger.

Where it gets interesting is with objects. In Svelte 5, $state makes object properties deeply reactive via a Proxy. Assign user.age = 30 and the component updates. That's a genuine improvement over Svelte 4, where you'd need user = { ...user, age: 30 } reassignment tricks to trigger reactivity on nested properties.

<script>
  // Deep reactivity — mutate freely
  let user = $state({ name: 'Alice', age: 28 });

  function birthday() {
    user.age++; // This works. No spread needed.
  }
</script>

<p>{user.name} is {user.age}</p>
<button onclick={birthday}>Happy birthday</button>

One more thing — $state in a .js or .ts file works just as well. That's the whole point. You can write a reusable createCounter() helper and export it, and the reactivity travels with it.

$derived: Computed Values Without the Footguns

If $state is your source of truth, $derived is everything calculated from it. You had $: reactive declarations in Svelte 4. Runes replace that with something more explicit — and more predictable.

<script>
  let width = $state(400);
  let height = $state(300);

  // Recalculates whenever width or height changes
  let area = $derived(width * height);
  let aspectRatio = $derived((width / height).toFixed(2));
</script>

<p>Area: {area}px — Ratio: {aspectRatio}</p>

The key constraint: $derived is read-only. You can't assign to it. If you catch yourself wanting to, you've probably got the dependency direction backwards — step back and rethink where the state actually lives.

For more complex derived logic (think filtering, sorting, anything with multiple steps), use $derived.by with a function body:

<script>
  let items = $state(['banana', 'apple', 'cherry']);
  let query = $state('');

  let filtered = $derived.by(() => {
    const q = query.toLowerCase();
    return items
      .filter(item => item.includes(q))
      .sort();
  });
</script>

<input bind:value={query} placeholder="Search..." />
<ul>
  {#each filtered as item}
    <li>{item}</li>
  {/each}
</ul>

Look, $derived.by is just a cleaner $derived for multi-line logic. Use it whenever the expression doesn't fit on one line. There's no performance difference — the compiler handles both the same way.

$effect: Side Effects Without the Stale Closure Trap

Here's where developers coming from React feel the most at home — and sometimes bring their bad habits with them. $effect runs after the DOM updates, re-runs when its reactive dependencies change, and cleans up with a return function. Sound familiar?

<script>
  let theme = $state('dark');

  $effect(() => {
    document.body.setAttribute('data-theme', theme);

    // Cleanup runs before the next effect and on destroy
    return () => {
      document.body.removeAttribute('data-theme');
    };
  });
</script>

The big difference from React's useEffect: you don't declare a dependency array. Svelte tracks which reactive values you *read* inside the effect and subscribes automatically. If you read theme, the effect re-runs when theme changes. Read nothing reactive, it runs once. Simple.

That said, this auto-tracking has one gotcha. If you read a reactive value conditionally — say inside an if block that sometimes doesn't execute — Svelte might not track it. Write your effects so that reactive reads happen unconditionally, or use $derived to pre-compute the value before the effect.

<script>
  let userId = $state(null);
  let userData = $state(null);

  $effect(() => {
    // userId is always read first — guaranteed tracking
    const id = userId;
    if (!id) return;

    fetch(`/api/users/${id}`)
      .then(r => r.json())
      .then(data => { userData = data; });
  });
</script>

Quick aside: $effect.pre exists for when you need to run logic *before* the DOM updates — rare, but useful for things like scroll position management or canvas calculations. Default to $effect unless you have a specific reason to go pre.

$props and $bindable: The Component Interface

Runes also change how you declare component props. In Svelte 4, export let value = 'default' was the convention. That was... fine. But it mixed export semantics with prop semantics, which always felt a bit off.

<script>
  // Destructure directly — defaults work naturally
  let { label = 'Click me', disabled = false, onclick } = $props();
</script>

<button {disabled} {onclick}>{label}</button>

The destructuring pattern is immediately readable. Want a rest spread for forwarding extra attributes? let { label, ...rest } = $props(). TypeScript users get proper generic typing: let props: { label: string } = $props() or the cleaner interface syntax.

For two-way binding — when a parent wants to bind:value on your component — use $bindable:

<!-- CustomInput.svelte -->
<script>
  let { value = $bindable(''), placeholder = '' } = $props();
</script>

<input bind:value {placeholder} />

<!-- Parent.svelte -->
<script>
  import CustomInput from './CustomInput.svelte';
  let username = $state('');
</script>

<CustomInput bind:value={username} placeholder="Your name" />
<p>Hello, {username}</p>

Honestly, $bindable makes the binding contract explicit in a way that Svelte 4's implicit approach never did. You know exactly which props support two-way binding just by looking at the component source.

Sharing State Across Components (Without Stores)

This is the runes feature that changes architecture decisions most. In Svelte 4, shared reactive state meant Svelte stores — writable(), readable(), the $store subscription syntax. Stores still work in Svelte 5, but you don't need them for most use cases.

Since $state works in plain .svelte.js files (note the .svelte.js extension — that's the tell), you can create reactive state modules:

// theme.svelte.js
let current = $state('dark');

export function getTheme() {
  return current;
}

export function setTheme(value) {
  current = value;
}

export function toggleTheme() {
  current = current === 'dark' ? 'light' : 'dark';
}
<!-- Any component -->
<script>
  import { getTheme, toggleTheme } from './theme.svelte.js';
</script>

<button onclick={toggleTheme}>
  Current: {getTheme()}
</button>

That pattern replaces 80% of store usage. The remaining 20% — streams, custom subscription logic, interop with non-Svelte code — is where stores still earn their keep. When you're building component libraries or custom UI kits (the kind you'd find browsing Empire UI), this module pattern scales nicely without the boilerplate of explicit subscriptions.

Practical Patterns and Pitfalls

You'll hit a few rough edges when you first switch over. The most common: trying to use runes outside a .svelte or .svelte.js file. Runes are compiler features — they won't work in a plain .js file. The error message is clear enough, but it's still a "wait, what?" moment at 11pm.

The second pitfall is over-using $effect where $derived is the right tool. If your effect reads reactive values and writes reactive values, that's a derived computation wearing an effect costume. Refactor to $derived and the code gets shorter and the mental overhead drops.

<script>
  let items = $state([1, 2, 3, 4, 5]);

  // BAD: Using $effect to compute derived data
  let total = $state(0);
  $effect(() => {
    total = items.reduce((sum, n) => sum + n, 0);
  });

  // GOOD: $derived is made for this
  let total2 = $derived(items.reduce((sum, n) => sum + n, 0));
</script>

For UI work — think interactive demos, component showcases, anything with visual state — the runes model pairs well with component libraries. When I'm prototyping something with glassmorphism components or building a config panel for a gradient generator, having shared reactive state in a .svelte.js module means no prop-drilling and no store boilerplate.

One more thing — the Svelte 5 migration guide is genuinely good. The official svelte-migrate CLI handles a lot of the mechanical conversion (stores to runes, $: labels to $derived, etc.) if you're porting a Svelte 4 codebase. It won't get everything, but it gets the boring parts.

FAQ

Can I mix Svelte 4 syntax and runes in the same project?

Yes — Svelte 5 supports both. You can't mix them in the *same* component file, but different components can use whichever style they were written in. Migrate incrementally.

Do $state variables work in TypeScript?

Fully. TypeScript infers types from the initial value, or you can be explicit: let count = $state<number>(0). Props typing uses the same generic syntax as before.

Is there a performance difference between runes and Svelte 4 reactivity?

Runes are at least as fast and often faster, because the compiler generates more precise update code. The deep-reactive Proxy for objects has a small overhead, but it's negligible for typical UI workloads.

When should I still use Svelte stores in Svelte 5?

When you need custom subscription logic, reactive streams, or interop with non-Svelte code that expects a store interface. For everything else, a .svelte.js module with $state is simpler.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

SvelteKit Guide 2026: Routing, Load Functions, Forms and DeploySvelteKit Authentication: Lucia, Better Auth and Cookie SessionsReact vs Svelte in 2026: Honest Comparison After 5 Years of SvelteKitSolidJS vs React: Fine-Grained Reactivity vs Virtual DOM