Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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, and most “not working” reports come from carrying Svelte 4 habits into Svelte 5 code.

In Svelte 4, the compiler turned every top-level let declaration in a <script> block into a reactive variable, and $: labels created reactive statements. Reactivity was implicit and component-local. In Svelte 5, reactivity is explicit and rune-based: only values created with $state, $derived, or $effect participate in tracking, and runes work across file boundaries when used in .svelte.ts modules. This is a significant compile-time shift — the compiler treats $state(0) as a special form, not a function call, which is why importing it from a library doesn’t work and why it must appear in a Svelte-processed file.

The platform layer is where surprises hide. Runes require Svelte 5, which requires SvelteKit 2 or later. The Vite plugin (@sveltejs/vite-plugin-svelte) must be version 4+ to process .svelte.ts files. Storybook only added native Svelte 5 support in Storybook 8.4 via @storybook/sveltekit or @storybook/svelte-vite. Testing tools (Vitest, Playwright) need configuration updates because the rune compilation step happens inside the Vite pipeline, not at TypeScript check time. Skip any of these upgrades and you get cryptic errors that look like rune bugs but are actually toolchain mismatches.

  • $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?.()} -->

Fix 7: Toolchain and Version Compatibility

Runes are a compiler feature, and the toolchain around the compiler has to line up exactly.

Svelte 4 vs Svelte 5 migration:

Svelte 5 supports a mixed mode where Svelte 4 components and Svelte 5 components coexist. The compiler detects which mode a file is in by looking for rune usage — if no runes appear, the file is treated as Svelte 4 (sometimes called “legacy mode”). This lets you migrate one component at a time, but it also means a partially-migrated file can fail silently: declaring let count = 0 in a component that already uses $state elsewhere keeps that variable in legacy mode, and refs to it from runes-aware code won’t track changes.

<script>
  // Mixed mode trap
  let count = 0;            // Legacy reactive — doesn't trigger updates outside this file
  let total = $state(0);    // Runes reactive — tracked everywhere

  // The compiler still treats count as legacy because the declaration is `let`
</script>

For lingering Svelte 4 store patterns that you haven’t migrated yet, see Fix: Svelte Store Not Updating.

SvelteKit version compatibility:

Runes need SvelteKit 2.0+ and @sveltejs/vite-plugin-svelte 4.0+. Older versions of either reject .svelte.ts files or fail to compile rune syntax. Check package.json:

{
  "devDependencies": {
    "@sveltejs/kit": "^2.0.0",
    "@sveltejs/vite-plugin-svelte": "^4.0.0",
    "svelte": "^5.0.0",
    "vite": "^5.0.0"
  }
}

For SvelteKit-specific routing, load functions, and adapter issues see Fix: SvelteKit Not Working.

Vite plugin requirements:

The Svelte Vite plugin runs the compiler on every .svelte and .svelte.ts file. If you have a custom vite.config.ts, ensure the plugin’s extensions array includes .svelte.ts:

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [sveltekit()],
  // No extra config needed for runes — the kit plugin handles .svelte.ts automatically
});

For a standalone Vite app (no SvelteKit), use @sveltejs/vite-plugin-svelte directly and ensure compilerOptions.runes defaults to undefined (auto-detect):

import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [svelte({ compilerOptions: { runes: true } })],
  // Setting runes: true forces runes mode for ALL files, including Svelte 4 components
});

$state in plain TypeScript via .svelte.ts:

You cannot use runes in a regular .ts file — the syntax compiles to nothing and you get a runtime error. The convention is .svelte.ts for shared state modules:

// lib/cart.svelte.ts — note the double extension
let items = $state<CartItem[]>([]);
let total = $derived(items.reduce((sum, i) => sum + i.price * i.quantity, 0));

export function getCart() {
  return {
    get items() { return items; },
    get total() { return total; },
    add(item: CartItem) { items.push(item); },
    clear() { items = []; },
  };
}

Importers see cart.svelte.ts exports as normal, but the compiler runs the Svelte transform on the file before TypeScript sees it. ESLint and Prettier may need .svelte.ts added to their parse lists.

Storybook 8 adapter:

Storybook 8.4+ supports Svelte 5 runes via @storybook/sveltekit or @storybook/svelte-vite. Older Storybook versions silently strip rune syntax and render stories with broken reactivity. If your stories show stale data after props change, check the Storybook version. For broader Storybook configuration debugging see Fix: Storybook Not Working.

Comparison with SolidJS reactivity:

Svelte 5’s $state and $derived are conceptually close to SolidJS’s createSignal and createMemo, but SolidJS tracks at runtime via function calls (count() to read) while Svelte transforms reads at compile time (count reads directly). If you’re coming from Solid and your runes feel “lazier than expected,” it’s because Svelte tracks reads at the assignment level, not the access level — see Fix: SolidJS Not Working for the comparison points that catch developers off guard.

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.

Hot reload loses state on rune changes — Vite’s HMR for Svelte preserves component state across reloads, but only for components in pure runes mode. Mixed-mode files (some legacy let, some $state) trigger a full reload on every save. Migrate the file fully to runes mode and HMR will start preserving state again.

$effect runs twice in dev — this is StrictMode-like behavior introduced for safer development. Production builds run it once. If you’re seeing double network requests in dev, that’s why; check your cleanup function (the returned function) actually cancels the in-flight request, not just the next one.

bind:value on a $state object property throws — two-way binding to a deeply nested $state property works only via $bindable on a prop. For local bindings, bind to the top-level state directly or use a getter/setter pair. The error message is unhelpful; the fix is usually restructuring the data shape.

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