Fix: Svelte Store Not Updating — Reactive Store Issues
Part of: React & Frontend Errors
Quick Answer
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.
The Problem
A Svelte store value changes but the component template doesn’t reflect the update:
// store.ts
import { writable } from 'svelte/store';
export const count = writable(0);
// component.svelte — template doesn't update
count.update(n => n + 1); // Called but template stays at 0Or a derived store doesn’t update when its source changes:
const doubled = derived(count, $count => $count * 2);
// doubled stays stale after count changesOr a store mutation (not assignment) doesn’t trigger reactivity:
// items is a writable store containing an array
items.update(arr => {
arr.push(newItem); // Mutates — sometimes doesn't trigger reactivity
return arr;
});Or the store subscription fires but the template doesn’t re-render.
Why This Happens
Svelte’s reactivity is compiler-driven. The Svelte compiler rewrites your code at build time, transforming $store references into subscribe/unsubscribe calls and assignments into invalidation flags. This is fundamentally different from runtime reactivity systems like Vue’s Proxy or Angular’s Zone.js. The benefit is zero runtime overhead for tracking dependencies. The cost is that certain patterns that look correct to a JavaScript developer are invisible to the compiler.
The most common failure is mutating an object or array inside update() and returning the same reference. When you call arr.push(newItem) and return arr, the store’s set() function fires, subscribers are notified, and the new value is technically available. But derived stores and some component bindings compare by reference. If the reference hasn’t changed, they skip the update. This is especially insidious because console.log inside a subscriber shows the correct value, yet the template stays frozen.
A second failure mode involves the $ prefix. In .svelte files, $count is a compiler directive that auto-subscribes and returns the current value. In .ts or .js files, the $ syntax doesn’t exist — it’s a Svelte compiler feature, not a JavaScript feature. Using count (without $) in a template shows [object Object] because you’re rendering the store object itself, not its value. This mistake is obvious once you know about it, but the error message (“object Object”) gives no hint about stores.
A third pattern is module-level store initialization in SvelteKit. Stores declared at the module top level are shared across all server-side requests. During SSR, user A’s data leaks into user B’s render because they share the same store instance. The fix is passing data through SvelteKit’s load() function and $page.data, not through module-scoped stores.
- Not using the
$prefix — the$storeauto-subscription syntax is compiled into a subscription/unsubscription. Usingstorewithout the$gives you the store object, not its current value. - Manual subscription without unsubscribing — calling
store.subscribe()without returning the unsubscribe function inonDestroycauses stale subscriptions and memory leaks. - Derived store dependency not tracked — derived store calculations only track dependencies accessed during the first synchronous execution. Conditional access to stores may break the dependency graph.
- Store set in a non-reactive context — setting a store inside a callback or async operation not connected to Svelte’s reactivity requires special handling.
How Other Tools Handle Reactivity
Svelte’s compile-time store model is one of several approaches to reactive state. Understanding the alternatives shows why certain Svelte-specific bugs exist and how the ecosystem is converging on signals.
Svelte 5 Runes ($state, $derived, $effect) replace the store pattern for local and shared state. Runes are also compiler-driven, but they eliminate the writable/derived/subscribe API entirely. A $state([]) array is deeply reactive — calling .push() on it triggers updates automatically, solving the mutation-vs-assignment problem that plagues Svelte 4 stores. If you’re on Svelte 5, runes are the recommended approach for new code, though the store API still works for backward compatibility.
Vue’s ref() and reactive() use JavaScript Proxies at runtime. A reactive({ items: [] }) wraps the object in a Proxy that intercepts property access and array methods like .push(). Mutations trigger updates automatically, so the “return a new reference” rule doesn’t apply. The tradeoff is a runtime cost for the Proxy and certain gotchas when destructuring (the destructured variable loses reactivity unless you use toRefs()).
React’s useState forces immutable updates by design. The setter function replaces the entire state value, and React schedules a re-render for the component. There’s no mutation-based reactivity at all — state.push(item) does nothing because React never knows about it. This means React developers hit the same “forgot to create a new reference” bug that Svelte 4 store users do, but for a different reason (React doesn’t track mutations; Svelte’s compiler does but requires new references for derived stores).
Solid’s createSignal is a fine-grained runtime signal. Reading a signal inside JSX subscribes that specific DOM node to the signal — no component re-render happens. Solid’s createStore supports nested reactivity with path-based updates (setStore('items', items.length, newItem)), which avoids both the mutation problem and the full-object-replacement overhead.
Zustand (framework-agnostic, popular with React) exposes a create() function that returns a hook. State updates use set() with a partial state object, and subscribers are notified via shallow comparison. Zustand’s model is closest to Svelte’s writable store in API shape, but it runs entirely at runtime with no compiler involvement.
The industry trend is toward fine-grained signals (Solid, Angular 16+, Svelte 5 runes, Preact signals). These systems track dependencies at the individual-value level rather than the component level, eliminating most of the “view not updating” bugs that stores and useState produce.
Fix 1: Use the $store Auto-Subscription Syntax
In Svelte components, prefix the store name with $ to automatically subscribe and get the current value:
<!-- WRONG — using the store object directly, not the current value -->
<script>
import { count } from './store';
// count is the store object, not the value
</script>
<p>Count: {count}</p> <!-- Shows [object Object], not the value -->
<button on:click={() => count.update(n => n + 1)}>+</button><!-- CORRECT — use $count to access the reactive value -->
<script>
import { count } from './store';
// $count automatically subscribes and gives the current value
</script>
<p>Count: {$count}</p> <!-- Shows the actual number, updates automatically -->
<button on:click={() => count.update(n => n + 1)}>+</button>The $ prefix is only valid inside .svelte component files. In .ts or .js files, subscribe manually:
// utils.ts — no $store syntax available here
import { get } from 'svelte/store';
import { count } from './store';
// Read the current value once
const currentCount = get(count);
// Subscribe to changes
const unsubscribe = count.subscribe(value => {
console.log('count changed:', value);
});
// Always unsubscribe when done
unsubscribe();Fix 2: Use Immutable Updates in Stores
When a store holds an object or array, return a new reference from update() to ensure all subscribers and derived stores detect the change:
// store.ts
import { writable } from 'svelte/store';
export const items = writable<string[]>([]);
// WRONG — mutating in place, same array reference returned
items.update(arr => {
arr.push('new item'); // Mutates the original array
return arr; // Same reference — derived stores may not update
});
// CORRECT — return a new array
items.update(arr => [...arr, 'new item']); // New reference — all subscribers notifiedSame for objects:
export const user = writable({ name: 'Alice', age: 30 });
// WRONG — mutating the object
user.update(u => {
u.name = 'Bob'; // Mutates
return u; // Same reference
});
// CORRECT — spread to create new object
user.update(u => ({ ...u, name: 'Bob' }));For nested structures:
export const config = writable({
theme: 'light',
layout: { sidebar: true, compact: false }
});
// Update nested property immutably
config.update(c => ({
...c,
layout: { ...c.layout, compact: true }
}));Common Mistake: Using
Array.prototype.sort()orArray.prototype.reverse()in an update — these mutate in place. Use spread first:([...arr].sort(...)).
Fix 3: Fix Derived Store Dependency Tracking
derived() tracks which stores are accessed synchronously. Conditional store access breaks the dependency graph:
import { writable, derived } from 'svelte/store';
const isMetric = writable(true);
const value = writable(100);
// WRONG — derived only tracks the stores accessed in the first run
// If isMetric starts as true, 'value' is always tracked,
// but the derivation may not update correctly in all cases
const display = derived([isMetric, value], ([$isMetric, $value]) => {
return $isMetric ? `${$value} km` : `${$value * 0.621} mi`;
});
// CORRECT — always access all dependent stores in the array
// The correct pattern above actually works — pass all deps in the array
// The issue arises when using a single store arg with conditional access:
// WRONG — conditional single-store derived
const problematic = derived(isMetric, ($isMetric) => {
if ($isMetric) {
return get(value) + ' km'; // 'value' not tracked by derived!
}
return get(value) * 0.621 + ' mi';
});
// CORRECT — list all stores in the array
const correct = derived(
[isMetric, value],
([$isMetric, $value]) => $isMetric ? `${$value} km` : `${$value * 0.621} mi`
);Derived store with async updates:
// For async derived values, use the callback form
const asyncData = derived(
userId,
($userId, set) => {
set(null); // Set initial value while loading
fetch(`/api/users/${$userId}`)
.then(r => r.json())
.then(data => set(data));
// Return cleanup function
return () => {
// Cancel any in-flight requests if needed
};
},
null // Initial value
);Fix 4: Manually Subscribe with Proper Cleanup
When you need to subscribe in non-reactive code (inside onMount, event handlers, or plain TypeScript files):
<!-- WRONG — subscribe without cleanup causes memory leak and stale updates -->
<script>
import { onMount } from 'svelte';
import { data } from './store';
let currentData;
onMount(() => {
data.subscribe(value => {
currentData = value;
});
// No cleanup — subscription stays active after component is destroyed
});
</script><!-- CORRECT — return unsubscribe from onMount for auto-cleanup -->
<script>
import { onMount } from 'svelte';
import { data } from './store';
let currentData;
onMount(() => {
const unsubscribe = data.subscribe(value => {
currentData = value;
});
return unsubscribe; // Svelte calls this on component destroy
});
</script>
<!-- Or use the $store syntax which handles this automatically -->
<p>{$data}</p>In a .ts file:
// service.ts
import { data } from './store';
export function watchData() {
const unsubscribe = data.subscribe(value => {
console.log('data:', value);
});
// Call when you want to stop watching
return unsubscribe;
}Fix 5: Build Custom Stores
Custom stores expose subscribe, set, and update while adding domain-specific methods:
// cart.store.ts
import { writable, derived, get } from 'svelte/store';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
function createCartStore() {
const { subscribe, set, update } = writable<CartItem[]>([]);
return {
subscribe, // Required for $store syntax and derived stores
addItem(item: Omit<CartItem, 'quantity'>) {
update(items => {
const existing = items.find(i => i.id === item.id);
if (existing) {
// Immutable update — new array and new object
return items.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { ...item, quantity: 1 }];
});
},
removeItem(id: string) {
update(items => items.filter(i => i.id !== id));
},
clear() {
set([]);
},
};
}
export const cart = createCartStore();
// Derived store — automatically updates when cart changes
export const cartTotal = derived(cart, $cart =>
$cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
export const cartCount = derived(cart, $cart =>
$cart.reduce((sum, item) => sum + item.quantity, 0)
);<!-- CartComponent.svelte -->
<script>
import { cart, cartTotal, cartCount } from './cart.store';
</script>
<p>Items: {$cartCount}</p>
<p>Total: ${$cartTotal.toFixed(2)}</p>
{#each $cart as item}
<div>
{item.name} x {item.quantity}
<button on:click={() => cart.removeItem(item.id)}>Remove</button>
</div>
{/each}Fix 6: Persist Stores to localStorage
For stores that should survive page reloads:
// persistent.store.ts
import { writable } from 'svelte/store';
export function persistentWritable<T>(key: string, initial: T) {
// Read initial value from localStorage if available
const stored = typeof localStorage !== 'undefined'
? localStorage.getItem(key)
: null;
const value = stored ? JSON.parse(stored) : initial;
const store = writable<T>(value);
// Subscribe and persist every change
store.subscribe(currentValue => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, JSON.stringify(currentValue));
}
});
return store;
}
// Usage
export const theme = persistentWritable('theme', 'light');
export const userPrefs = persistentWritable('prefs', { language: 'en', notifications: true });<script>
import { theme } from './persistent.store';
</script>
<!-- Value persists across reloads -->
<select bind:value={$theme}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>Note:
localStorageis not available during server-side rendering (SvelteKit SSR). Always guard withtypeof localStorage !== 'undefined'or useonMountfor localStorage access.
Still Not Working?
Verify the store is exported and imported correctly. If you import from two different module paths (relative vs absolute), you get two separate store instances:
// Two different import paths = two separate instances
import { count } from './store'; // Instance A
import { count } from '../lib/store'; // Instance B — different object!
// Changes to instance A don't affect instance BUse Svelte DevTools (browser extension) to inspect store values in real time. The extension shows all writable stores, their current values, and a history of changes. If the store updates but the template doesn’t, the issue is in the component; if the store doesn’t update, the issue is in the store logic.
Check for SvelteKit SSR issues. Stores initialized at module level are shared across all server-side requests unless you use context-based stores:
// WRONG for SSR — module-level store shared across requests
export const user = writable(null);
// CORRECT for SSR — use SvelteKit's $page.data or context API
// Pass data through load functions and page.dataCheck for reactive statement issues in Svelte 5 (Runes). Svelte 5 introduces $state, $derived, and $effect as replacements for the store pattern. If you’re on Svelte 5, the store API still works but the new runes are the preferred approach:
<!-- Svelte 5 runes (alternative to stores for local state) -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log('count changed:', count);
});
</script>
<p>{count} x 2 = {doubled}</p>
<button onclick={() => count++}>+</button>Watch for store initialization order in SvelteKit layouts. If a layout component subscribes to a store and a child page sets the store in its load() function, the layout may render before the store is populated. Use {#await} blocks or provide initial values to prevent a flash of empty state.
Check for circular derived store dependencies. If store A derives from store B and store B derives from store A, the update loop stalls silently. Svelte does not throw an error for circular derived stores — the derived value simply stops updating. Restructure the dependency graph so it flows in one direction.
Verify that set() is called, not just the callback return. In async derived stores, the set function passed as the second argument is the only way to update the value. Returning a value from the callback does nothing in the async form — you must call set(value) explicitly.
For related issues, see Fix: Svelte 5 Runes Not Working, Fix: Svelte Store Subscription Leak, Fix: Vue Reactive Data Not Updating, and Fix: React useState Not Updating.
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 5 Runes Not Working — $state Not Reactive, $derived Not Updating, or $effect Running Twice
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.
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.