Designing State Boundaries with Svelte Stores: An Architecture Note
An architecture note on Svelte stores: when shared state justifies a store, the read-only-export pattern that enforces a single write boundary, SSR caveats, and the leak and reactivity failure modes to check for.
28 Feb 2026, 16:43 UTC

Prop drilling breaks down fast in Svelte once two distant components need the same value. The store contract solves this, but the real decision is not whether to use stores — it is where to draw the boundary between shared state and local state, and who is allowed to write. This note covers the smallest design that works, the trust boundaries worth enforcing, and the failure modes that appear in production.
Everything below assumes Svelte 3/4-style stores (svelte/store). Svelte 5 introduces runes as an alternative reactivity model; stores still work, but if you are starting a fresh Svelte 5 project, verify which model your team standardizes on before committing.
Requirements the design must meet
- State shared across components that are not parent/child (e.g., session user, feature flags, cart contents).
- A single point of mutation, so debugging a wrong value means reading one file, not grepping the app.
- No memory leaks from subscriptions that outlive their components.
- Derived values (totals, filtered lists) that recompute automatically instead of being duplicated by hand.
Anything that only one component — or a parent and its direct children — needs should stay as local let state or props. A store you create for one consumer is indirection without payoff.
The smallest suitable design
The store contract is deliberately tiny: any object with a .subscribe(fn) method that calls fn with the current value and returns an unsubscribe function. Svelte's built-ins cover three cases:
writable— state you own and mutate viasetandupdate.derived— state computed from one or more stores; dependency tracking is automatic.readable— state set once or fed by an external source (timers, sockets).
The pattern that holds up is: keep the writable private inside a module, export only a read-only view plus intention-named functions.
// src/lib/session.js
import { writable, derived } from 'svelte/store';
const _user = writable(null);
// Read-only view: consumers can subscribe but cannot call set/update.
export const user = { subscribe: _user.subscribe };
export const isAdmin = derived(user, ($user) => $user?.role === 'admin');
export async function signIn(credentials) {
const u = await api.login(credentials); // your API layer
_user.set(u);
}
export function signOut() {
_user.set(null);
}Components consume it with the $ prefix, which auto-subscribes on mount and auto-unsubscribes on destroy:
<script>
import { user, isAdmin, signOut } from '$lib/session';
</script>
{#if $user}
<p>{$user.name} {#if $isAdmin}(admin){/if}</p>
<button on:click={signOut}>Sign out</button>
{/if}To verify the wiring: render two unrelated components that both read $user, call signIn from one, and confirm both update in the same frame. Then confirm isAdmin flips without any component touching it directly.
Trust and data boundaries
Two boundaries matter more than the store itself:
Write boundary. Exporting a raw writable means any component can set it, and a stray $user = null in a leaf component becomes a mystery logout. The read-only-export pattern above makes mutation impossible outside the module — enforced by the type system if you use TypeScript, since the exported object lacks set.
Server/client boundary. In SvelteKit, module-level stores are shared per-request on the server. Per-user state in a module-level store can leak between requests during SSR. The standard fix is to initialize per-request stores in a layout load function and pass them via setContext, or only touch user-specific stores in browser-only code. Check your SvelteKit version's current guidance — this is the detail most worth verifying against the docs before shipping.
Manual subscriptions and the leak failure mode
The $ syntax only works at component top level. Inside lifecycle code or plain TypeScript modules you subscribe manually, and the returned unsubscribe function is your responsibility:
import { onDestroy } from 'svelte';
import { user } from '$lib/session';
const unsubscribe = user.subscribe((u) => {
if (u) analytics.identify(u.id);
});
onDestroy(unsubscribe);Skipping onDestroy(unsubscribe) keeps the callback alive after the component is gone — each remount adds another. To check: open the browser's memory profiler, repeatedly mount and destroy a component-heavy view, take heap snapshots, and confirm listener/detached-node counts return to baseline. For one-shot reads outside components, prefer get(store) from svelte/store over subscribing at all.
Other failure modes
- Reactivity requires assignment.
updatewith a mutated-then-returned object works becauseupdatereassigns internally, but mutating a nested object and never callingset/updatenotifies nobody. With large nested state, prefer storing flat slices or immutable updates so change notification stays predictable. - Derived chains that do too much. A
derivedrecomputes on every upstream change. Cheap derivations (filters, sums) are fine; expensive ones (parsing, sorting thousands of rows) should be memoized or moved behind an explicit action. - Store sprawl. When everything is global, mutation order becomes the bug source. The read-only-export discipline plus keeping stores domain-scoped (session, cart, ui-preferences) rather than one mega-store is what keeps this manageable.
Conditions that would change the design
- Svelte 5 greenfield: evaluate runes (
$state,$derived) in.svelte.jsmodules first; they cover the same ground with fine-grained reactivity. - Multi-tab synchronization or offline persistence: stores alone don't persist; you'd add a subscription that writes to
localStorageor IndexedDB and rehydrates on init. - High-frequency updates (cursor positions, streaming ticks): every subscriber re-renders per update; throttle at the source or keep that state local to the one component that displays it.
Start with one private writable, a read-only export, and named mutation functions. That design scales further than it looks, and the boundaries it enforces are exactly the ones that fail first when skipped.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.