The Context API
Context avoids prop drilling
Section titled “Context avoids prop drilling”When many nested components need the same data — a theme, the current user, a form’s shared state — passing it through every intermediate component as props (prop drilling) is tedious and noisy. Context lets an ancestor set a value that any descendant can read directly.
setContext(key, value)— called in a component during initialization; makesvalueavailable to all descendants underkey.getContext(key)— called in any descendant; reads the nearest ancestor’s value forkey.
<script> import { setContext } from 'svelte'; setContext('theme', 'dark'); let { children } = $props();</script>{@render children()}<!-- DeepChild.svelte (any depth below) --><script> import { getContext } from 'svelte'; const theme = getContext('theme'); // 'dark' — no props threaded through</script><div class={theme}>…</div>flowchart TB
parent["Ancestor: setContext('theme', value)"] --> mid["intermediate components
(no props needed)"]
mid --> child["Descendant: getContext('theme')"] Reactive context with runes
Section titled “Reactive context with runes”setContext runs once, at init — the value it stores isn’t reactive by itself. To share reactive data, put a runed object into context and read its properties in descendants; because $state objects are deeply reactive, changes propagate.
<!-- Store a runed object, not a plain snapshot --><script> import { setContext } from 'svelte'; let cart = $state({ items: [] }); setContext('cart', cart); // descendants see live updates to cart.items</script>A common pattern is a small factory that creates the runed state and pairs setContext/getContext behind typed helper functions, so consumers just call getCart().
Proving the difference: a plain object vs a $state object
Section titled “Proving the difference: a plain object vs a $state object”<script lang="ts"> import { getContext } from 'svelte'; const cart = getContext<{ items: string[] }>('cart');</script><span data-testid="count">{cart.items.length}</span><script lang="ts"> import { setContext } from 'svelte'; import ContextConsumer from './ContextConsumer.svelte'; const cart = { items: [] as string[] }; // BUG: plain object, not reactive setContext('cart', cart);</script><button data-testid="add" onclick={() => cart.items.push('x')}>add</button><ContextConsumer /><script lang="ts"> import { setContext } from 'svelte'; import ContextConsumer from './ContextConsumer.svelte'; const cart = $state({ items: [] as string[] }); // fixed: runed state setContext('cart', cart);</script><button data-testid="add" onclick={() => cart.items.push('x')}>add</button><ContextConsumer />import { test, expect } from 'vitest';import { render, fireEvent } from '@testing-library/svelte';import ContextProviderPlain from './ContextProviderPlain.svelte';import ContextProviderReactive from './ContextProviderReactive.svelte';
test('a plain object in context does NOT update the UI on mutation', async () => { const { container } = render(ContextProviderPlain); const count = () => container.querySelector('[data-testid="count"]')!.textContent; expect(count()).toBe('0'); await fireEvent.click(container.querySelector('[data-testid="add"]')!); // still '0': the descendant never re-renders because a plain object isn't reactive expect(count()).toBe('0');});
test('a $state object in context DOES update the UI on mutation', async () => { const { container } = render(ContextProviderReactive); const count = () => container.querySelector('[data-testid="count"]')!.textContent; expect(count()).toBe('0'); await fireEvent.click(container.querySelector('[data-testid="add"]')!); expect(count()).toBe('1');});✓ a plain object in context does NOT update the UI on mutation✓ a $state object in context DOES update the UI on mutationSame setContext/getContext calls, same mutation (cart.items.push('x')) — the only difference is whether the value passed to setContext was wrapped in $state. No error, no warning either way: a plain object in context silently never triggers a re-render.
Context vs props vs global state
Section titled “Context vs props vs global state”- Props — best for direct parent→child data. Explicit and easy to trace.
- Context — best when data is needed by many descendants at varying depths (theme, auth, a widget’s shared state) and is scoped to a subtree.
- Module state (
.svelte.js) — best for truly app-global state, but mind SSR (a module singleton is shared across requests on the server — the state-management module covers this).
Crucially, context is per component tree and per render, so on the server each request gets its own context — making it the SSR-safe way to share request-scoped state, unlike a module-level singleton.
Mental Model
Section titled “Mental Model”Context is a read channel bound to the component tree, not a value store of its own — setContext just registers “here’s the value for this key, for whoever renders below me.” Whether that value is reactive is entirely up to what you put in it: a plain object is a frozen snapshot handed down once; a $state object is a live reference descendants read from, proven above. Context itself never re-runs, re-checks, or diffs anything — all the reactivity, if any, comes from the value’s own runes.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Context over prop drilling | No threading a value through components that don’t use it | Less explicit than props — a descendant’s data source isn’t visible at its call site |
$state object in context | Live updates propagate to every descendant, proven above | Easy to forget the wrapper — a plain object silently never updates, proven above, with no error |
| Context over a module singleton | Per-render, per-tree — safe for request-scoped state on the server | Only reachable by descendants of the component that called setContext; siblings/ancestors can’t read it |
Common Misconceptions
Section titled “Common Misconceptions”- “Putting any object in
setContextmakes it reactive.” It doesn’t — proven above, a plain object mutated aftersetContextproduces zero UI update, silently. Only a$state-wrapped value is live. - “If context isn’t reactive, Svelte will warn you.” It won’t — the two test cases above run identically except for one
$state()wrapper, and only one of them fails to update, with no console output either way. - “Context is basically a global variable.” It’s scoped to a component tree and to a single render — the state-and-stores module covers why that specifically matters for SSR, where a real module-level singleton would leak between requests.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated context code, check for:
- Is the value passed to
setContexta plain object/array literal ({ items: [] },[]) that later gets mutated, expecting descendants to see the update? Proven above: without$state, they won’t. - Does a “cart” / “auth” / “theme” provider component reassign the context value on every change (
setContext('cart', newCart)inside an event handler) instead of mutating a$stateobject once set at init? RepeatedsetContextcalls after init are a sign the code is fighting the one-time-at-init design instead of using it. - Is
getContextcalled anywhere outside component initialization (inside asetTimeout, or after anawait)? LikesetContext, it must run during a component’s synchronous init — called too late, there’s no “current component” to attach to.
🐛 Spot the bug
<script>import { setContext } from 'svelte';import CartTotal from './CartTotal.svelte';
const cart = { items: [] }; // looks fine at a glancesetContext('cart', cart);
function addItem(item) { cart.items.push(item); // mutating the "shared" cart}</script>
<button onclick={() => addItem({ name: 'Tea', price: 2 })}>Add tea</button><CartTotal />Show the answer
cart is a plain object literal, never wrapped in $state. Calling addItem genuinely pushes into cart.items — the data is correct — but because cart isn’t reactive, <CartTotal> (which reads it via getContext('cart')) never re-renders to reflect the new total, proven above with an identical repro. There’s no error and no warning; the total just silently stays wrong until something unrelated causes a re-render. The fix is one word: const cart = $state({ items: [] });.