5 min read
On this page

Reactive State with Runes

Svelte 5's reactivity system is built on three runes: $state for mutable values, $derived for computed values, and $effect for side effects. Each has a specific role, and using the right one for the right job is the foundation of writing clean Svelte code.

$state: Mutable State

$state declares a reactive variable. When its value changes, anything that depends on it updates automatically.

<script>
  let count = $state(0);
  let name = $state('');
  let items = $state<string[]>([]);
</script>

<button onclick={() => count++}>Count: {count}</button>
<input bind:value={name} placeholder="Your name">
<button onclick={() => items.push('new item')}>Add item ({items.length})</button>

The initial value passed to $state can be any type: primitive, object, array, or null. The variable behaves like a normal JavaScript variable after declaration. You read it, write to it, and pass it to functions. The compiler handles the reactivity wiring.

When to Use $state

Use $state when you have a value that:

  • Changes over time due to user interaction, API responses, or timers.
  • Needs to trigger UI updates when it changes.
  • Is owned by this component (not derived from other values).
<script lang="ts">
  let isOpen = $state(false);
  let selectedTab = $state<'home' | 'settings' | 'profile'>('home');
  let formData = $state({
    email: '',
    password: '',
    rememberMe: false
  });
</script>

<button onclick={() => isOpen = !isOpen}>
  {isOpen ? 'Close' : 'Open'} Menu
</button>

Deep Reactivity: Arrays & Objects

Objects and arrays passed to $state are deeply reactive. Svelte wraps them in a proxy that tracks reads and writes at every level of nesting.

<script>
  let todos = $state([
    { id: 1, text: 'Buy groceries', done: false },
    { id: 2, text: 'Walk the dog', done: true }
  ]);
</script>

<ul>
  {#each todos as todo}
    <li>
      <input type="checkbox" bind:checked={todo.done}>
      <span class:done={todo.done}>{todo.text}</span>
    </li>
  {/each}
</ul>

<button onclick={() => todos.push({ id: Date.now(), text: 'New todo', done: false })}>
  Add Todo
</button>

<style>
  .done { text-decoration: line-through; }
</style>

Mutations like todos.push(...), todo.done = true, or todos[0].text = 'Updated' all trigger reactivity. You do not need to create new arrays or spread objects. This is a significant difference from React, where you must treat state as immutable.

$state.raw: Opting Out of Deep Reactivity

For large datasets or objects you do not need to mutate in place, $state.raw creates state without the deep proxy. Updates only trigger when the entire value is reassigned.

<script>
  // Large dataset: deep proxying would be wasteful
  let rows = $state.raw(generateLargeDataset());

  function refresh() {
    // Must reassign entirely to trigger updates
    rows = generateLargeDataset();
  }

  // This will NOT trigger an update:
  // rows[0].name = 'changed';

  // This WILL trigger an update:
  // rows = [...rows];
</script>

Use $state.raw when:

  • The data is large and you never mutate individual properties.
  • The data comes from an API and you always replace it wholesale.
  • You want to avoid the overhead of deep proxy wrapping.

$derived: Computed Values

$derived creates a value that is automatically computed from reactive dependencies. It is read-only and recalculates lazily when its dependencies change.

<script>
  let items = $state([
    { name: 'Apple', price: 1.50, quantity: 3 },
    { name: 'Bread', price: 2.99, quantity: 1 },
    { name: 'Milk', price: 3.49, quantity: 2 }
  ]);

  let totalItems = $derived(items.reduce((sum, item) => sum + item.quantity, 0));
  let totalPrice = $derived(
    items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  let formattedTotal = $derived(`$${totalPrice.toFixed(2)}`);
</script>

<p>{totalItems} items, total: {formattedTotal}</p>

When to Use $derived

Use $derived when:

  • A value is computed entirely from other reactive values.
  • You want the computation to stay in sync automatically.
  • The value should be read-only (nobody should set it directly).

The rule is simple: if you can express a value as a function of other state, use $derived. Do not use $state and then manually keep it in sync with an $effect.

<script>
  let search = $state('');
  let items = $state(['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']);

  // Correct: derived from search and items
  let filtered = $derived(
    items.filter(item => item.toLowerCase().includes(search.toLowerCase()))
  );
</script>

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

$derived.by for Complex Computations

When the computation needs multiple statements, use $derived.by:

<script>
  let transactions = $state([
    { amount: 100, type: 'credit' },
    { amount: -50, type: 'debit' },
    { amount: 200, type: 'credit' },
    { amount: -30, type: 'debit' }
  ]);

  let summary = $derived.by(() => {
    let credits = 0;
    let debits = 0;

    for (const tx of transactions) {
      if (tx.type === 'credit') credits += tx.amount;
      else debits += Math.abs(tx.amount);
    }

    return {
      credits,
      debits,
      balance: credits - debits
    };
  });
</script>

<p>Credits: ${summary.credits} | Debits: ${summary.debits} | Balance: ${summary.balance}</p>

$effect: Side Effects

$effect runs code in response to reactive changes. It automatically tracks which reactive values it reads and re-runs when any of them change.

<script>
  let query = $state('');
  let results = $state([]);

  $effect(() => {
    if (query.length < 3) {
      results = [];
      return;
    }

    const controller = new AbortController();

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal
    })
      .then(r => r.json())
      .then(data => { results = data; })
      .catch(() => {});

    return () => controller.abort();
  });
</script>

<input bind:value={query} placeholder="Search (3+ chars)...">
<ul>
  {#each results as result}
    <li>{result.title}</li>
  {/each}
</ul>

When to Use $effect

Use $effect for genuine side effects:

  • Fetching data from APIs.
  • Setting up event listeners or subscriptions.
  • Updating document.title or other browser APIs.
  • Synchronizing with external systems (localStorage, WebSocket).
  • Logging or analytics.

Do not use $effect to compute derived values. If you find yourself writing $effect(() => { someVar = computation(); }), that should be $derived instead.

Cleanup

The function returned from $effect runs before the next re-execution and when the component is destroyed:

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

  $effect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e) => { theme = e.matches ? 'dark' : 'light'; };

    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  });
</script>

The Reactivity Contract

Svelte's reactivity follows predictable rules:

  1. $state changes are synchronous, but DOM updates are batched. If you update three state variables, Svelte batches the DOM updates into a single microtask.
  2. $derived values are lazy. They only recalculate when read, not immediately when dependencies change.
  3. $effect runs after DOM updates. When an effect runs, the DOM already reflects the latest state. This makes it safe to measure DOM elements inside effects.
  4. Dependencies are tracked automatically. You never declare a dependency list. Svelte observes which reactive values are actually read during execution.
  5. Reactivity is per-assignment for primitives. count++ triggers an update. For objects/arrays with $state, mutations are tracked through the proxy.
<script>
  let a = $state(1);
  let b = $state(2);
  let sum = $derived(a + b);

  $effect(() => {
    // This effect depends on `sum`, which depends on `a` and `b`.
    // Changing either `a` or `b` triggers `sum` to recalculate,
    // which triggers this effect.
    console.log(`Sum is now: ${sum}`);
  });
</script>

Common Pitfalls

  • Using effecttosetstatethatshouldbeeffect to set state that should be derived: This creates unnecessary indirection and can cause extra render cycles. If the value is a pure function of other state, use $derived.
  • **Destructuring stateobjects:letx,y=state objects**: `let { x, y } = state({ x: 1, y: 2 })gives you non-reactive copies. Keep the object reference:let point = $state({ x: 1, y: 2 })and accesspoint.x`.
  • Reading reactive values conditionally in $effect: If your effect only reads a value inside an if branch, the dependency is only tracked when that branch executes. This can lead to effects not re-running when you expect.
  • **Heavy computation in derivedwithoutmemoization:derived without memoization**: `derivedrecalculates when dependencies change, so an expensive filter or sort on a large array runs on every dependency change. Consider$state.raw` for the source data if appropriate.
  • Forgetting cleanup in $effect: Subscriptions, intervals, and event listeners created in an effect must be cleaned up via the return function to avoid memory leaks.

Key Takeaways

  • $state is for mutable values that change over time and trigger UI updates. Objects and arrays are deeply reactive by default.
  • $derived is for computed values that stay in sync with their dependencies automatically. Use it instead of manually syncing state.
  • $effect is for side effects: API calls, event listeners, browser API access. It runs after DOM updates and supports cleanup.
  • $state.raw opts out of deep reactivity for large datasets or values you replace rather than mutate.
  • Dependencies are tracked automatically. There are no dependency arrays to manage.
  • The golden rule: if a value can be computed from other state, it should be $derived, not $state synced via $effect.