Skip to content

Fix: Svelte Store Not Updating — Reactive Store Issues

FixDevs · (Updated: )

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 0

Or a derived store doesn’t update when its source changes:

const doubled = derived(count, $count => $count * 2);
// doubled stays stale after count changes

Or 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 $store auto-subscription syntax is compiled into a subscription/unsubscription. Using store without the $ gives you the store object, not its current value.
  • Manual subscription without unsubscribing — calling store.subscribe() without returning the unsubscribe function in onDestroy causes 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 notified

Same 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() or Array.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: localStorage is not available during server-side rendering (SvelteKit SSR). Always guard with typeof localStorage !== 'undefined' or use onMount for 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 B

Use 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.data

Check 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.

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