Fix: Svelte 5 Runes Not Working — $state Not Reactive, $derived Not Updating, or $effect Running Twice
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:
$statecreates 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.$derivedrecomputes when its dependencies change — it tracks which$statevalues 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.$effecttracks dependencies and re-runs on changes — any$stateor$derivedvalue read inside$effectbecomes a dependency. Writing to a$stateinside the same$effectcreates a dependency cycle that re-triggers the effect.- Runes only work in
.sveltefiles and.svelte.tsmodules —$state,$derived, and$effectare compile-time features. They don’t work in plain.tsor.jsfiles. Use.svelte.tsfor 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Paraglide Not Working — Messages Not Loading, Compiler Errors, or Framework Integration Issues
How to fix Paraglide.js i18n issues — message compilation, type-safe translations, SvelteKit and Next.js integration, language switching, and message extraction from existing code.
Fix: Svelte Store Not Updating — Reactive Store Issues
How to fix Svelte store not updating the UI — writable vs readable stores, derived stores, subscribe pattern, store mutation vs assignment, and custom store patterns.
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.