Cross-Component State
Pattern 1: a shared module
Section titled “Pattern 1: a shared module”The simplest cross-component state is a reactive value exported from a .svelte.js module. Every importer shares one instance:
export const theme = $state({ mode: 'light' });
export function toggle() { theme.mode = theme.mode === 'light' ? 'dark' : 'light';}Any component imports theme and toggle and stays in sync. This is ideal for genuinely global, single-instance state: the color theme, a toast queue, a feature-flag set.
The SSR singleton trap
Section titled “The SSR singleton trap”There’s a catch that matters the moment you use SvelteKit (or any SSR). A module-level $state is a single instance shared by everything that imports it — including, on the server, every user request handled by the same process. Put a logged-in user in a module-level singleton and, under SSR, one request can see another user’s data.
This exact mechanism — one process-wide object, mutated by “request 1,” silently visible to a component that never touched request 1 — is already proven directly, with a real effect and two simulated “requests,” in the $state Rune lesson. It’s the same trap whether the shared value holds one field or, as here, is used to coordinate several components: the risk comes from the export being module-level, not from how many components read it.
Pattern 2: per-subtree instances with context
Section titled “Pattern 2: per-subtree instances with context”When you need state that is shared within a subtree but isolated per instance — and safe under SSR — combine a runed class/object with setContext / getContext. The parent creates the state and puts it in context; descendants read it. Each component instance gets its own, created during that request’s render.
<script> import { setContext } from 'svelte'; import CartItemAdder from './CartItemAdder.svelte';
// created fresh per instance / per request — not a module singleton const cart = $state({ items: [] }); setContext('cart', cart);</script>
<CartItemAdder /><span data-testid="parent-count">{cart.items.length}</span><script> import { getContext } from 'svelte'; const cart = getContext('cart'); // the same reactive object the parent set</script>
<button data-testid="add" onclick={() => cart.items.push({ id: cart.items.length })}> add ({cart.items.length})</button>Because the state is created inside the component tree (not at module load), each render — and each SSR request — gets its own copy. Proven directly: mounting CartProvider twice in a row (standing in for two separate “requests”) never carries state over from one mount to the next, and two instances mounted at the same time never see each other’s changes either:
import { render, fireEvent, cleanup, within } from '@testing-library/svelte';import { afterEach, expect, test } from 'vitest';import CartProvider from './CartProvider.svelte';
afterEach(() => cleanup());
test('context-provided state is created fresh PER MOUNT (per "request"), unlike a module singleton', async () => { // "Request 1" const r1 = render(CartProvider); await fireEvent.click(within(r1.container).getByTestId('add')); expect(within(r1.container).getByTestId('parent-count').textContent).toBe('1'); r1.unmount();
// "Request 2" — a brand new mount. If cart were a module-level $state // singleton (like the SSR trap proven in reactivity/state-rune), this would // start at 1 (leaked from request 1). With context, it starts fresh. const r2 = render(CartProvider); expect(within(r2.container).getByTestId('parent-count').textContent).toBe('0'); r2.unmount();});
test('two concurrently mounted instances never see each other\'s context state', async () => { const a = render(CartProvider); const b = render(CartProvider);
await fireEvent.click(within(a.container).getByTestId('add')); await fireEvent.click(within(a.container).getByTestId('add'));
expect(within(a.container).getByTestId('parent-count').textContent).toBe('2'); expect(within(b.container).getByTestId('parent-count').textContent).toBe('0'); // untouched
a.unmount(); b.unmount();});✓ context-provided state is created fresh PER MOUNT (per "request"), unlike a module singleton✓ two concurrently mounted instances never see each other's context state
Test Files 1 passed (1) Tests 2 passed (2)A component mount standing in for a “request” is of course a simplification — it doesn’t exercise SvelteKit’s real server request pipeline. But it proves the actual mechanism context relies on for SSR safety: state declared inside a component’s <script> is instantiated fresh every time that component is rendered, with no shared object connecting separate render passes. That’s precisely what makes it safe across concurrent SSR requests, each of which renders the component tree independently. A common idiom is a small factory (createCart(), proven independent-per-call in the Runes vs Stores lesson) that returns runed state, called in the parent and shared via context.
Per-request state in SvelteKit: setContext in +layout.svelte
Section titled “Per-request state in SvelteKit: setContext in +layout.svelte”The pattern above is exactly what a real SvelteKit app uses +layout.svelte for: a layout’s <script> runs fresh on every render of that layout, so state created and put into context there is naturally per-request, never a cross-request singleton.
<script> import { setContext } from 'svelte';
let { children } = $props();
// Created fresh on EVERY render of this layout — never a module singleton, // so it can never leak between concurrent requests on the server. const cart = $state({ items: [] }); setContext('cart', cart);</script>
{@render children()}<script> import { getContext } from 'svelte'; const cart = getContext('cart');</script>
<button onclick={() => cart.items.push({ id: cart.items.length })}> add to cart ({cart.items.length})</button>Every route nested under this layout shares the same cart instance via context — exactly the subtree-sharing behavior demonstrated above with CartProvider/CartItemAdder — but scoped to one request’s render of the layout, not to the module. Contrast this directly with the trap two sections up: export const cart = $state({ items: [] }) in a .svelte.js module imported by +layout.svelte would create exactly one cart for the whole server process; setContext inside the layout’s own <script> creates one per render instead.
The decision guide
Section titled “The decision guide”- Local —
$statein the component. Start here. - Lift — move
$stateto the closest common parent, pass down as props. For a handful of related components. - Context —
setContext/getContextwith runed state for a subtree that needs its own instance (and SSR safety) — proven above to be created fresh per render, never shared across mounts. - Module — export
$statefrom a.svelte.jsmodule for truly global, single-instance state. - Store — when you need the subscribe contract (interop, external push sources).
Reach for the lowest level that solves your problem; escalate only when you must.
flowchart TB local["Just one component? local $state"] --> up["A few related components? lift $state to the parent, pass props"] up --> ctx["A subtree, per-instance / per-user? setContext + runed state"] ctx --> mod["Truly global, single instance? export $state from a .svelte.js module"] mod --> store["Need the subscribe contract? a store"]
Mental Model
Section titled “Mental Model”Every one of these patterns answers the same question — “where does this reactive object get created?” — with a different scope. A module-level export is created once, at import time, for the life of the process: that’s exactly why it’s shared by everything, forever, including across unrelated server requests. State created inside a component’s <script> (context included) is created fresh every time that component instance is set up — so context isn’t “safer” by some special SvelteKit magic, it’s safer because it’s built on ordinary component instantiation, which already happens once per render/request by construction. Lifting to a parent is the same idea at the smallest possible scope: the parent component instance is the “container” the state is created inside.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Local $state | Simplest; nothing to wire up | Only visible to the one component |
| Lift to parent + props | Still simple; explicit data flow | Prop-drilling grows with tree depth |
setContext/getContext | Fresh per subtree instance, proven safe across separate mounts/requests | Slightly more ceremony (provider + consumer); implicit (not visible in props) |
Module .svelte.js singleton | Zero ceremony, reachable from anywhere | One instance for the whole process — proven unsafe for per-user data under SSR |
Store (writable/readable) | The subscribe contract, for interop | More ceremony than runes for plain app state |
Common Misconceptions
Section titled “Common Misconceptions”- “Context and module state are both just
$state, so they behave the same way under SSR.” Proven false (via the $state Rune lesson’s SSR test plus the mount-isolation proof above): module state is one instance for the whole process; context-provided state is created fresh every render, so two concurrent renders never share an instance. - “
setContextin+layout.svelteneeds some SvelteKit-specific per-request API to be safe.” It doesn’t — it’s safe for exactly the same reason any component-local state is safe: the layout’s<script>runs fresh every time the layout renders, which is once per relevant request. - “Lifting state to a parent and using context are basically the same amount of ceremony, so pick whichever.” Lifting means passing props down explicitly (visible, but grows with depth); context is implicit (no props) but requires both a provider and a consumer to agree on a key — different trade-offs, not equivalent ceremony.
- “A factory function used inside context (
createCart()) shares state with a factory used at module scope.” Proven false in the Runes vs Stores lesson: a factory always returns an independent instance per call, regardless of where that call happens — what determines sharing vs. isolation is the call site and export shape, not the factory itself.
Reading AI-generated code
Section titled “Reading AI-generated code”When you see shared state wired across components, check for these:
- Is state that should be per-user or per-request (a logged-in user, a session, a cart tied to a login) exported directly from a
.svelte.jsmodule, instead of created inside a component/layout and shared via context? Proven above: the module version is one instance for the whole server process. - In a SvelteKit app, is
setContextcalled inside a+layout.svelte’s own<script>(safe, fresh per render) — or does the state it sets actually come from an imported module-level singleton (unsafe, defeats the point of using context at all)? - Does a deeply nested component receive the same three or four values as props through five layers of components that don’t use them themselves — a sign that
setContext/getContext(or lifting less far) would remove real, unnecessary ceremony? - Is
getContextcalled for a key that was never actually set by an ancestor on the path that will render — returningundefinedat runtime with no compile-time check to catch it?
🐛 Spot the bug
export const session = $state({ user: null });
// src/routes/+layout.svelte<script>import { setContext } from 'svelte';import { session } from '../lib/session.svelte.ts';
// "Make the session available to the whole app via context"setContext('session', session);
let { children } = $props();</script>
{@render children()}Show the answer
This looks like it’s using context for per-request safety, but it isn’t: session is imported from a .svelte.js/.svelte.ts module, where it was already created as a single, process-wide instance — proven in the $state Rune lesson to be shared by every importer, for the whole server process. Wrapping that same shared object in setContext doesn’t create a new instance per request; it just hands descendants a reference to the same module-level singleton, one layer removed. Every concurrent request on this server process gets the identical session object, so one user’s session.user overwrites the value every other concurrent request sees — exactly the SSR leak the context pattern exists to prevent, still present, just harder to spot because setContext/getContext are involved. The fix is to create the state inside the layout’s own <script> — const session = $state({ user: null }) written directly in +layout.svelte, not imported from a module — so a fresh instance exists per render, the same as the proven-safe CartProvider/+layout.svelte examples above.