Skip to content

Fix: Svelte 5 Runes Not Working — $state Not Reactive, $derived Not Updating, or $effect Running Twice

FixDevs ·

Quick Answer

How to fix Svelte 5 Runes issues — $state and $state.raw reactivity, $derived computations, $effect lifecycle, $props and $bindable, migration from Svelte 4 stores, and component patterns.

The Problem

A $state variable doesn’t trigger re-renders when changed:

<script>
  let count = $state(0);

  function increment() {
    count++;
    console.log(count); // 1, 2, 3... — value updates
  }
</script>

<button onclick={increment}>Count: {count}</button>
<!-- Button shows "Count: 0" and never updates -->

Or $derived returns stale values:

<script>
  let items = $state([1, 2, 3]);
  let total = $derived(items.reduce((sum, n) => sum + n, 0));

  function addItem() {
    items.push(4);
  }
</script>

<p>Total: {total}</p>
<!-- Total stays at 6 even after push -->

Or $effect runs in an infinite loop:

<script>
  let data = $state(null);

  $effect(() => {
    data = fetchData(); // Infinite loop — setting state triggers the effect again
  });
</script>

Why This Happens

Svelte 5 introduces Runes — a new reactivity system that replaces Svelte 4’s let reactivity and stores. The mental model is different:

  • $state creates deeply reactive proxies for objects/arrays — primitive values (numbers, strings) work with simple reassignment. But for arrays, push() and other mutations are tracked through a Proxy. If the proxy is lost (e.g., by destructuring into a plain variable), reactivity breaks.
  • $derived recomputes when its dependencies change — it tracks which $state values are read during its evaluation. If the derived expression doesn’t read the state correctly (e.g., the reduce runs on the initial array reference), updates are missed.
  • $effect tracks dependencies and re-runs on changes — any $state or $derived value read inside $effect becomes a dependency. Writing to a $state inside the same $effect creates a dependency cycle that re-triggers the effect.
  • Runes only work in .svelte files and .svelte.ts modules$state, $derived, and $effect are compile-time features. They don’t work in plain .ts or .js files. Use .svelte.ts for shared reactive state.

Fix 1: $state Reactivity Basics

<script lang="ts">
  // Primitives — reassignment triggers updates
  let count = $state(0);
  let name = $state('Alice');
  let isOpen = $state(false);

  // Objects — deeply reactive via Proxy
  let user = $state({ name: 'Alice', age: 30 });
  // Mutate directly — this works
  function updateName() {
    user.name = 'Bob';  // ✅ Tracked by Proxy
  }

  // Arrays — mutations are tracked
  let items = $state(['Apple', 'Banana']);
  function addItem() {
    items.push('Cherry');       // ✅ Tracked
    items[0] = 'Avocado';      // ✅ Tracked
    items.splice(1, 1);        // ✅ Tracked
  }

  // WRONG — replacing the variable with a non-reactive copy
  function brokenReset() {
    let plain = items;  // plain is the same proxy, OK
    // But if you do:
    // items = [...items, 'new'];  // ✅ This works — reassignment
  }

  // $state.raw — no deep proxy (better for large data or classes)
  let bigData = $state.raw<DataPoint[]>([]);
  function updateBigData(newData: DataPoint[]) {
    bigData = newData;  // Must reassign entirely — mutations not tracked
    // bigData.push(item);  // ❌ Won't trigger update with $state.raw
  }
</script>

<p>{count}</p>
<button onclick={() => count++}>Increment</button>

<p>{user.name} is {user.age}</p>
<button onclick={updateName}>Change Name</button>

<ul>
  {#each items as item}
    <li>{item}</li>
  {/each}
</ul>
<button onclick={addItem}>Add Item</button>

Fix 2: $derived Computations

<script lang="ts">
  let items = $state([
    { name: 'Apple', price: 1.5, quantity: 3 },
    { name: 'Banana', price: 0.75, quantity: 5 },
    { name: 'Cherry', price: 3.0, quantity: 2 },
  ]);

  let searchQuery = $state('');

  // Simple derived value
  let total = $derived(
    items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  // Derived with filtering
  let filteredItems = $derived(
    items.filter(item =>
      item.name.toLowerCase().includes(searchQuery.toLowerCase())
    )
  );

  // Derived count
  let itemCount = $derived(items.length);

  // Complex derived — use $derived.by() for multi-line logic
  let summary = $derived.by(() => {
    const count = items.length;
    const totalValue = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const avgPrice = count > 0 ? totalValue / count : 0;

    return {
      count,
      totalValue: totalValue.toFixed(2),
      avgPrice: avgPrice.toFixed(2),
      mostExpensive: items.reduce((max, i) =>
        i.price > max.price ? i : max, items[0]
      ),
    };
  });

  function addItem() {
    items.push({ name: 'Date', price: 5.0, quantity: 1 });
    // total, filteredItems, itemCount, summary all update automatically
  }
</script>

<input bind:value={searchQuery} placeholder="Search..." />
<p>Showing {filteredItems.length} of {itemCount} items</p>
<p>Total: ${total.toFixed(2)}</p>
<p>Average: ${summary.avgPrice}</p>

{#each filteredItems as item}
  <div>
    {item.name} — ${item.price} × {item.quantity}
    <button onclick={() => item.quantity++}>+1</button>
  </div>
{/each}

Fix 3: $effect Lifecycle

<script lang="ts">
  let searchQuery = $state('');
  let results = $state<string[]>([]);
  let windowWidth = $state(0);

  // $effect — runs after the component mounts, and when dependencies change
  $effect(() => {
    // Reads searchQuery → becomes a dependency
    const query = searchQuery;

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

    // Debounce with cleanup
    const timer = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      results = await res.json();
    }, 300);

    // Cleanup function — runs before re-execution and on unmount
    return () => clearTimeout(timer);
  });

  // $effect for browser APIs
  $effect(() => {
    function handleResize() {
      windowWidth = window.innerWidth;
    }

    handleResize();  // Initial value
    window.addEventListener('resize', handleResize);

    return () => window.removeEventListener('resize', handleResize);
  });

  // $effect.pre — runs BEFORE DOM updates (like beforeUpdate in Svelte 4)
  let scrollContainer: HTMLDivElement;
  let messages = $state<string[]>([]);

  $effect.pre(() => {
    // Read messages.length to track it
    messages.length;
    // Scroll logic runs before DOM update
  });

  // Avoid infinite loops — don't write to state that's also read
  // WRONG:
  // $effect(() => {
  //   data = transform(data);  // Reads and writes data → infinite loop
  // });

  // CORRECT — use $derived instead:
  let rawData = $state([1, 2, 3]);
  let transformed = $derived(rawData.map(n => n * 2));

  // CORRECT — use untrack if you must write in an effect
  import { untrack } from 'svelte';
  $effect(() => {
    const value = someState;  // Track this
    untrack(() => {
      otherState = compute(value);  // Don't track the write
    });
  });
</script>

<input bind:value={searchQuery} placeholder="Search..." />
<p>Window: {windowWidth}px</p>

{#each results as result}
  <p>{result}</p>
{/each}

Fix 4: $props and Component Communication

<!-- Button.svelte -->
<script lang="ts">
  // $props — replaces export let
  let {
    variant = 'primary',
    size = 'md',
    disabled = false,
    onclick,          // Event handler prop
    children,         // Slot content (Svelte 5 snippets)
    ...restProps      // Spread remaining props
  }: {
    variant?: 'primary' | 'secondary' | 'danger';
    size?: 'sm' | 'md' | 'lg';
    disabled?: boolean;
    onclick?: (e: MouseEvent) => void;
    children?: import('svelte').Snippet;
    [key: string]: any;
  } = $props();

  const classes = $derived(
    `btn btn-${variant} btn-${size} ${disabled ? 'btn-disabled' : ''}`
  );
</script>

<button class={classes} {disabled} {onclick} {...restProps}>
  {@render children?.()}
</button>
<!-- Parent.svelte -->
<script lang="ts">
  import Button from './Button.svelte';

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

<Button variant="primary" size="lg" onclick={() => count++}>
  Clicked {count} times
</Button>

<Button variant="danger" onclick={() => count = 0}>
  Reset
</Button>
<!-- $bindable — two-way binding -->
<!-- Input.svelte -->
<script lang="ts">
  let { value = $bindable(''), placeholder = '' }: {
    value?: string;
    placeholder?: string;
  } = $props();
</script>

<input bind:value {placeholder} />

<!-- Usage: -->
<script>
  let name = $state('');
</script>
<Input bind:value={name} placeholder="Enter name" />
<p>Name: {name}</p>

Fix 5: Shared Reactive State (.svelte.ts)

Runes work in .svelte.ts files for shared state across components:

// lib/counter.svelte.ts — shared reactive state
export function createCounter(initial = 0) {
  let count = $state(initial);

  return {
    get count() { return count; },  // Getter preserves reactivity
    increment() { count++; },
    decrement() { count--; },
    reset() { count = initial; },
  };
}

// lib/auth.svelte.ts — shared auth state
interface User {
  id: string;
  name: string;
  email: string;
}

export function createAuthStore() {
  let user = $state<User | null>(null);
  let loading = $state(true);

  let isLoggedIn = $derived(user !== null);

  async function login(email: string, password: string) {
    loading = true;
    try {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      user = await res.json();
    } finally {
      loading = false;
    }
  }

  function logout() {
    user = null;
  }

  return {
    get user() { return user; },
    get loading() { return loading; },
    get isLoggedIn() { return isLoggedIn; },
    login,
    logout,
  };
}

// Create a singleton instance
export const auth = createAuthStore();
<!-- Any component -->
<script>
  import { auth } from '$lib/auth.svelte';
</script>

{#if auth.loading}
  <p>Loading...</p>
{:else if auth.isLoggedIn}
  <p>Welcome, {auth.user.name}</p>
  <button onclick={auth.logout}>Logout</button>
{:else}
  <button onclick={() => auth.login('[email protected]', 'password')}>Login</button>
{/if}

Fix 6: Migration from Svelte 4

<!-- Svelte 4 → Svelte 5 -->

<!-- BEFORE (Svelte 4) -->
<script>
  export let name = 'world';        // → $props
  let count = 0;                     // → $state
  $: doubled = count * 2;           // → $derived
  $: console.log(count);            // → $effect
  $: if (count > 10) reset();       // → $effect

  import { writable } from 'svelte/store';
  const store = writable(0);        // → $state in .svelte.ts
</script>

<button on:click={() => count++}>   // → onclick

<!-- AFTER (Svelte 5) -->
<script>
  let { name = 'world' } = $props();
  let count = $state(0);
  let doubled = $derived(count * 2);

  $effect(() => {
    console.log(count);
  });

  $effect(() => {
    if (count > 10) reset();
  });
</script>

<button onclick={() => count++}>

<!-- Event forwarding -->
<!-- Svelte 4: on:click -->
<!-- Svelte 5: onclick={handler} or use spread: {...restProps} -->

<!-- Slots → Snippets -->
<!-- Svelte 4: <slot /> -->
<!-- Svelte 5: {@render children?.()} -->

Still Not Working?

$state changes but UI doesn’t update — make sure the file extension is .svelte or .svelte.ts. Runes are compiler features that only work in Svelte-processed files. In a plain .ts file, $state is just an undefined variable. Also check you’re not destructuring the state into a plain variable: const { name } = user copies the value, losing reactivity. Access user.name directly.

$derived doesn’t recompute — derived values only track state read during evaluation. If you access state in an async callback or setTimeout inside $derived, it won’t be tracked. Keep $derived synchronous. For async derived data, use $effect to fetch and write to a separate $state.

$effect runs on server (SvelteKit SSR)$effect only runs in the browser, not during SSR. This is correct behavior. If you need something to run on both, use $effect.pre or handle it in the load function. If your effect accesses window or document, it’s correct that it only runs client-side.

Stores still work but feel deprecated — Svelte 4 stores (writable, readable, derived) still work in Svelte 5. But for new code, runes are preferred. To migrate gradually, you can use stores and runes in the same project. Convert stores to .svelte.ts modules with $state when you’re ready.

For related framework issues, see Fix: SvelteKit Not Working and Fix: SolidJS Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles