Many Mechanisms, One Decision
Svelte provides multiple mechanisms for sharing data between components: props, context, stores, events, module-level state, and integration points with SvelteKit’s data loading. Each exists for specific reasons, excels in particular scenarios, and carries distinct trade-offs. Rather than viewing these as competing options where one is “better,” understanding each pattern’s strengths helps you select the right tool for each situation.
This comprehensive guide presents a balanced comparison of all state management patterns in Svelte 5. You’ll see the same problems solved with different approaches, understand the trade-offs involved through visual diagrams, and develop intuition for when each pattern shines. Whether you’re building your first Svelte application or architecting a complex production system, this guide provides the decision framework you need.
By the end, you’ll have clarity on when context shines, when stores make more sense, when to stick with simple props, and when you might reach for something else entirely.
The State Management Landscape
Before diving into comparisons, let’s establish what each pattern offers and how they relate to each other. Understanding this landscape is the foundation for making good architectural decisions.
Pattern Overview
| Pattern | Scope | Direction | Reactivity | SSR Safety |
|---|---|---|---|---|
| Props | Parent → direct child | Down (one level) | Automatic | ✅ Safe |
| Context | Ancestor → any descendant | Down (multiple levels) | Manual ($state) | ✅ Safe |
| Stores | Anywhere in module scope | Any direction | Built-in | ⚠️ Requires care |
| Events | Child → parent | Up (one level) | N/A | ✅ Safe |
| Bindable Props | Parent ↔ child | Bidirectional | Automatic | ✅ Safe |
| Module State | All importers | Any direction | Manual ($state) | ❌ Dangerous |
| SvelteKit Load | Route → page/layout | Server → Client | Via props | ✅ Safe |
Each row represents a fundamentally different approach to the same problem: getting data from where it exists to where it’s needed.
Visual Architecture
The following diagram shows how data flows through different patterns in a typical Svelte application:
The solid arrows represent explicit data flow (props, events), while dashed arrows represent implicit flow (context). Notice how context “tunnels” through intermediate components, while props must pass through each level.
Understanding the Mental Models
Before comparing patterns, let’s understand how each one conceptually moves data through your application. This mental model is essential for choosing the right pattern.
Data Flow Directions
Each pattern serves a specific communication need:
Props are explicit contracts between parent and child. The child declares what it needs, and the parent provides it. This explicitness makes components self-documenting but requires threading data through every intermediate component.
Context creates an implicit channel from any ancestor to any descendant. Intermediate components don’t need to know about or forward the data. This solves prop drilling but introduces hidden dependencies.
Events (callback props in Svelte 5) notify parents of actions in children. They’re the complement to props, completing the parent-child communication loop.
Stores break out of the component tree entirely. Any code that imports a store shares the same reactive state, enabling patterns impossible with tree-scoped communication.
Scope Visualization
Understanding scope is critical, especially for SSR safety:
This diagram illustrates why module-level state is dangerous in SSR: both Request A and Request B share the same module state, potentially leaking data between users. Context creates isolated instances per component tree, making it inherently SSR-safe.
Context vs Props
This is the comparison most developers encounter first. When should you reach for context instead of passing props?
The Core Difference
Props flow data explicitly from parent to immediate child. Context flows data implicitly from any ancestor to any descendant.
When Props Excel
Props win for direct parent-child communication where explicitness matters:
<!-- ProductCard.svelte -->
<script lang="ts">
import ProductImage from './ProductImage.svelte'
import ProductTitle from './ProductTitle.svelte'
import ProductPrice from './ProductPrice.svelte'
interface Props {
product: Product
}
let { product }: Props = $props()
</script>
<article class="product-card">
<ProductImage src={product.image} alt={product.name} />
<ProductTitle title={product.name} />
<ProductPrice amount={product.price} currency="USD" />
</article> Each child declares exactly what it needs. The contract is explicit, typed, and documented by the code itself. You can read ProductImage.svelte and immediately understand its data requirements without hunting through ancestor components.
Props strengths:
- Explicit — You see exactly what data a component needs by reading its props
- Type-safe — Full TypeScript support with IDE autocomplete and error checking
- Discoverable — Props appear in component signatures, making APIs self-documenting
- Testable — Easy to provide different props in tests without mocking context
- Refactorable — Rename a prop and TypeScript shows you every usage
When Context Wins
Context wins when data must skip multiple levels, keeping intermediate components clean:
<!-- The Prop Drilling Problem -->
<App>
<!-- has user -->
<Layout {user}>
<!-- passes through -->
<Sidebar {user}>
<!-- passes through -->
<NavMenu {user}>
<!-- passes through -->
<UserAvatar {user} />
<!-- finally uses it -->
</NavMenu>
</Sidebar>
</Layout>
</App> <!-- The Context Solution -->
<App>
<!-- setContext('user', user) -->
<Layout>
<!-- clean! -->
<Sidebar>
<!-- clean! -->
<NavMenu>
<!-- clean! -->
<UserAvatar />
<!-- getContext('user') -->
</NavMenu>
</Sidebar>
</Layout>
</App> The context version eliminates four intermediate props. Layout, Sidebar, and NavMenu remain decoupled from user data they don’t use.
Context strengths:
- No drilling — Intermediate components stay clean and focused
- Decoupled — Components only declare what they actually use
- Scoped — Different subtrees can have different values via context shadowing
- Flexible — Add new context consumers without modifying intermediates
Context weaknesses:
- Implicit — Must read code to know what context a component needs
- Less discoverable — Dependencies not in component signature
- Testing overhead — Need provider wrappers in tests
Trade-off Summary
| Aspect | Props | Context |
|---|---|---|
| Dependencies | ✔ Explicit (self-documenting) | ✘ Implicit (hidden requirements) |
| TypeScript | ✔ Better inference | ⚠️ Good but less discoverable |
| Data flow tracing | ✔ Easier to trace | ⚠️ Requires checking providers |
| Reusability | ✔ No context dependency | ✘ Coupled to context structure |
| Intermediate components | ✘ Must pass through all | ✔ Skips intermediates |
| Coupling | ✘ Intermediates coupled to data | ✔ Reduces coupling |
| Ambient data | ⚠️ Verbose for theme/auth/locale | ✔ Better fit |
Best Fit Guide:
| Props Territory | Context Territory |
|---|---|
| Direct parent-child | 3+ levels deep |
| 1-2 levels deep | Compound components |
| Component library public API | Theme/Auth/Locale ambient data |
Side-by-Side Example: Theme Data
Let’s see both approaches solving the same problem:
Props approach:
<!-- App.svelte -->
<script>
let theme = $state({ mode: 'light', primary: '#3b82f6' })
</script>
<Layout {theme}>
<slot />
</Layout>
<!-- Layout.svelte -->
<script>
let { theme, children } = $props()
</script>
<Header {theme} />
<main>{@render children()}</main>
<Footer {theme} />
<!-- Header.svelte -->
<script>
let { theme } = $props()
</script>
<nav style:background={theme.mode === 'dark' ? '#1f2937' : '#fff'}>
<Logo {theme} />
<Navigation {theme} />
<ThemeToggle {theme} />
</nav> Context approach:
<!-- App.svelte -->
<script>
import { setContext } from 'svelte'
let theme = $state({ mode: 'light', primary: '#3b82f6' })
setContext('theme', {
get mode() {
return theme.mode
},
get primary() {
return theme.primary
},
toggle() {
theme.mode = theme.mode === 'light' ? 'dark' : 'light'
}
})
</script>
<Layout>
<slot />
</Layout>
<!-- Layout.svelte -->
<script>
let { children } = $props()
</script>
<Header />
<main>{@render children()}</main>
<Footer />
<!-- Header.svelte -->
<script>
import { getContext } from 'svelte'
const theme = getContext('theme')
</script>
<nav style:background={theme.mode === 'dark' ? '#1f2937' : '#fff'}>
<Logo />
<Navigation />
<ThemeToggle />
</nav> The context approach results in cleaner intermediate components (Layout no longer mentions theme), while the props approach makes theme dependencies visible in each component’s interface.
The Hybrid Approach
Production applications often combine both patterns strategically:
<!-- ProductCard.svelte -->
<script>
import { getContext } from 'svelte'
// Props for this component's direct concerns
let { product, variant = 'default' } = $props()
// Context for cross-cutting concerns
const cart = getContext('cart')
const theme = getContext('theme')
</script>
<article class="card {variant}" style="--bg: {theme.colors.surface}">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onclick={() => cart.addItem(product)}>Add to Cart</button>
</article> Product data (specific to this card) comes via props. Cross-cutting concerns (cart, theme) come via context. This separation keeps the component’s primary API explicit while avoiding prop drilling for ambient data.
Decision Guidance: Props vs Context
| Scenario | Best Choice | Reasoning |
|---|---|---|
| Direct parent-child | Props | Clear, explicit, fully typed |
| 1-2 levels deep | Props | Drilling overhead is manageable |
| 3+ levels deep | Context | Avoid coupling intermediate components |
| Component library public API | Props | Consumers need explicit, documented interface |
| Internal compound component state | Context | Implementation detail, not part of API |
| Different values per subtree | Context | Context shadowing enables this naturally |
| Maximum type safety required | Props | Better IDE support and refactoring |
| Theme, auth, locale | Context | Ambient data used throughout subtree |
Context vs Stores
Stores ($state at module level or Svelte’s reactive stores) provide another approach to shared state. The comparison here is more nuanced because stores and context solve overlapping but distinct problems.
The Core Difference
Context is tree-scoped: it flows down the component hierarchy and can have different values in different subtrees. Stores are import-scoped: any module that imports the store sees the same value.
With context, Subtree B can shadow the theme with ‘dark’, and all its descendants see the dark theme while Subtree A continues to see ‘light’. With stores, everyone sees the same value—there’s no way to have different themes in different parts of the app.
When Stores Excel
Stores shine for truly global state that’s identical everywhere:
// src/lib/stores/notifications.svelte.ts
interface Notification {
id: string
message: string
type: 'info' | 'success' | 'error'
}
let notifications = $state<Notification[]>([])
export function addNotification(message: string, type: Notification['type'] = 'info') {
const id = crypto.randomUUID()
notifications.push({ id, message, type })
setTimeout(() => removeNotification(id), 5000)
}
export function removeNotification(id: string) {
const index = notifications.findIndex((n) => n.id === id)
if (index !== -1) {
notifications.splice(index, 1)
}
}
export function getNotifications() {
return notifications
} Any component anywhere can trigger a notification:
<!-- DeepNestedComponent.svelte -->
<script>
import { addNotification } from '$lib/stores/notifications.svelte.js'
function handleSave() {
// ... save logic
addNotification('Saved successfully!', 'success')
}
</script>
<button onclick={handleSave}>Save</button> And a single UI component renders them all:
<!-- NotificationToast.svelte -->
<script>
import { getNotifications, removeNotification } from '$lib/stores/notifications.svelte.js'
const notifications = getNotifications()
</script>
<div class="toast-container">
{#each notifications as notification (notification.id)}
<div class="toast {notification.type}">
{notification.message}
<button onclick={() => removeNotification(notification.id)}>×</button>
</div>
{/each}
</div> Context would be awkward here—the notification trigger and display might be in completely different parts of the tree with no common ancestor.
Store strengths:
- Truly global — Same instance everywhere, no providers needed
- Works anywhere — Components, utilities, API clients all have access
- Persists — Data survives component unmounts and navigation
- Simple — Just import and use, no ceremony
When Context Wins Over Stores
Context wins when different parts of the app need different values:
<!-- Different cart contexts for different vendors -->
<MarketplacePage>
<!-- Vendor A's section with its own cart -->
<CartProvider storageKey="vendor-a-cart" taxRate={0.08}>
<VendorAProducts />
<VendorACart />
</CartProvider>
<!-- Vendor B's section with its own cart -->
<CartProvider storageKey="vendor-b-cart" taxRate={0.1}>
<VendorBProducts />
<VendorBCart />
</CartProvider>
</MarketplacePage> A store-based cart would force all vendors to share one cart. Context allows complete isolation by subtree.
Context strengths:
- SSR-safe — Each request gets fresh context automatically
- Subtree scoping — Different providers can coexist with different values
- Clear ownership — Provider component owns the state lifecycle
- Testable — Wrap with mock provider for testing
The SSR Problem with Stores
Critical: SSR Store DangerModule-level stores are dangerous in SSR environments. In SvelteKit, server-side rendering shares module scope across all requests. This means a store defined at module level is shared between all users, leading to data leaks and security vulnerabilities.
// ❌ DANGEROUS: Shared across all users on server
// src/lib/stores/user.svelte.ts
let currentUser = $state<User | null>(null)
export function setUser(user: User) {
currentUser = user // User A sets this...
}
export function getUser() {
return currentUser // ...User B sees User A's data!
} Request A sets currentUser to Alice. Before the response completes, request B arrives and reads currentUser — getting Alice’s data instead of Bob’s.
Here’s a visual representation of the SSR danger:
Safe Store Patterns for SSR
If you need stores with SSR, there are safe patterns to follow. You can combine context and factory functions to create per-request store instances. And because Context is bound to component instances, which are created fresh for each request there’s no risk of cross-request data leakage.
// Option 1: Factory function + context (best of both worlds)
// src/lib/user/context.ts
import { getContext, setContext } from 'svelte'
const USER_KEY = Symbol('user')
export function createUserStore() {
let user = $state<User | null>(null)
return {
get user() {
return user
},
setUser(u: User) {
user = u
},
clear() {
user = null
}
}
}
export function setUserContext(store: ReturnType<typeof createUserStore>) {
setContext(USER_KEY, store)
}
export function getUserContext() {
return getContext<ReturnType<typeof createUserStore>>(USER_KEY)
} <!-- +layout.svelte -->
<script>
import { createUserStore, setUserContext } from '$lib/user/context.js'
let { data, children } = $props()
// Fresh store per request (SSR-safe)
const userStore = createUserStore()
userStore.setUser(data.user)
setUserContext(userStore)
</script>
{@render children()} This gives you store-like ergonomics with context’s SSR safety.
Context is SSR-safeRemember: Context is inherently SSR-safe because each request gets its own component tree and thus its own context instances. This makes context the go-to choice for per-request state in SSR applications.
Combining Stores and Context
For SSR apps that need persistent client-side state:
<!-- CartProvider.svelte -->
<script>
import { setContext } from 'svelte'
import { browser } from '$app/environment'
let { children } = $props()
// SSR-safe initialization
let items = $state<CartItem[]>([])
// Hydrate from localStorage on client only
$effect(() => {
if (browser) {
const saved = localStorage.getItem('cart')
if (saved) {
const parsed = JSON.parse(saved)
items.length = 0
items.push(...parsed)
}
}
})
// Persist changes on client only
$effect(() => {
if (browser && items.length > 0) {
localStorage.setItem('cart', JSON.stringify(items))
}
})
setContext('cart', {
get items() {
return items
},
get total() {
return items.reduce((sum, i) => sum + i.price * i.quantity, 0)
},
addItem(product: Product, quantity = 1) {
const existing = items.find((i) => i.productId === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.push({
productId: product.id,
name: product.name,
price: product.price,
quantity
})
}
},
clear() {
items.length = 0
if (browser) {
localStorage.removeItem('cart')
}
}
})
</script>
{@render children()} This gives you SSR safety with client-side persistence—the best of both worlds.
Decision Guidance: Context vs Stores
| Scenario | Best Choice | Reasoning |
|---|---|---|
| SSR application | Context | Request isolation is automatic |
| Client-only state | Either | No SSR concerns to worry about |
| Different values per subtree | Context | Context shadowing enables this |
| Same value everywhere | Store | Simpler, no providers needed |
| Non-component access needed | Store | Works in utilities, API clients |
| Data survives navigation | Store | Persists across component unmounts |
| Testable components | Context | Mock via providers easily |
| External libraries need access | Store | Can import directly without context |
Context vs Module State
Module-level state is the simplest form of shared state: a variable exported from a module. Understanding the difference between this and context is crucial for avoiding production bugs.
The Core Difference
Module state is global by definition—every importer shares the same variable. Context creates isolated instances per provider.
// Module state: one value, period
// counter.svelte.ts
let count = $state(0)
export function getCount() {
return count
}
export function increment() {
count++
}
// Context: one value per provider
// In ComponentA: setContext('counter', createCounter())
// In ComponentB: setContext('counter', createCounter())
// ComponentA's children get a different counter than ComponentB's When Module State Makes Sense
Module state works for truly singleton concerns that:
- Are identical for all users
- Don’t need SSR isolation
- Never vary by location in the tree
// src/lib/config.ts
// Configuration loaded once at startup, never changes per-user
export const features = {
enableNewCheckout: import.meta.env.VITE_ENABLE_NEW_CHECKOUT === 'true',
showBetaBanner: import.meta.env.VITE_SHOW_BETA_BANNER === 'true',
maxUploadSize: 10 * 1024 * 1024
} // src/lib/analytics.ts
// Side-effect system, doesn't hold user state
export function trackEvent(name: string, properties?: Record<string, unknown>) {
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ name, properties, timestamp: Date.now() })
})
}
export function trackPageView(path: string) {
trackEvent('page_view', { path })
} These are genuinely singleton—the same for every user, every request.
Why Module State Fails for User Data
Production Bug AlertThis pattern destroys your app in production:
// ❌ NEVER DO THIS // src/lib/stores/auth.ts let currentUser = $state<User | null>(null) export function login(user: User) { currentUser = user } export function logout() { currentUser = null } export function getCurrentUser() { return currentUser }On the server:
- Request A logs in as Alice, sets
currentUser = alice- Request B arrives, calls
getCurrentUser(), gets… Alice- Request B renders Alice’s data for a completely different user
This isn’t a theoretical concern—it’s a production incident waiting to happen.
The Context Alternative
// src/lib/auth/context.ts
import { getContext, setContext } from 'svelte'
const AUTH_KEY = Symbol('auth')
export function createAuthContext() {
let user = $state<User | null>(null)
let loading = $state(true)
return {
get user() {
return user
},
get loading() {
return loading
},
get isAuthenticated() {
return user !== null
},
setUser(u: User | null) {
user = u
loading = false
},
logout() {
user = null
}
}
}
export function setAuthContext(ctx: ReturnType<typeof createAuthContext>) {
setContext(AUTH_KEY, ctx)
}
export function getAuthContext() {
return getContext<ReturnType<typeof createAuthContext>>(AUTH_KEY)
} Each request creates its own auth context. No cross-contamination possible.
Decision Guidance: Context vs Module State
| Scenario | Best Choice | Reasoning |
|---|---|---|
| Data is truly singleton (same for all) | Module state | Simple and appropriate |
| Static configuration or constants | Module state | Never changes, no isolation needed |
| Client-only applications | Either | No SSR concerns |
| Side-effect systems (analytics, logging) | Module state | No user state involved |
| Data varies by user or request | Context | Isolation is essential |
| Different parts of app need different values | Context | Module state can’t provide this |
| SSR applications with user data | Context | SSR safety is non-negotiable |
| Testing requires isolation | Context | Easy to provide mock context |
Context vs Events and Bindable Props
Events and bindable props handle different communication patterns than context. Understanding when to use each completes your component communication toolkit.
Events: Child-to-Parent Communication
In Svelte 5, events are implemented as callback props:
<!-- Child.svelte -->
<script lang="ts">
interface Props {
onselect?: (item: Item) => void
}
let { onselect }: Props = $props()
function handleClick(item: Item) {
onselect?.(item)
}
</script>
<button onclick={() => handleClick({ id: 1, name: 'Item' })}>
Select
</button>
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte'
function handleSelect(item) {
console.log('Selected:', item)
}
</script>
<Child onselect={handleSelect} /> Context for Deep Upward Communication
For communication that needs to skip levels going upward, put callbacks in context:
<!-- FormProvider.svelte -->
<script>
import { setContext } from 'svelte'
let { onsubmit, children } = $props()
let fields = $state<Record<string, unknown>>({})
let errors = $state<Record<string, string>>({})
function validate() {
// validation logic
return Object.keys(errors).length === 0
}
setContext('form', {
get fields() {
return fields
},
get errors() {
return errors
},
setField(name: string, value: unknown) {
fields[name] = value
},
setError(name: string, error: string) {
errors[name] = error
},
submit() {
if (validate()) {
onsubmit?.(fields)
}
}
})
</script>
{@render children()} <!-- DeepInput.svelte (many levels deep) -->
<script>
import { getContext } from 'svelte'
let { name, label } = $props()
const form = getContext('form')
</script>
<label>
{label}
<input
value={form.fields[name] ?? ''}
oninput={(e) => form.setField(name, e.target.value)}
/>
{#if form.errors[name]}
<span class="error">{form.errors[name]}</span>
{/if}
</label>
<!-- DeepSubmitButton.svelte -->
<script>
import { getContext } from 'svelte'
const form = getContext('form')
</script>
<button onclick={form.submit}>Submit</button> The form provider exposes a submit method in context, which internally calls the onsubmit callback prop. This bridges context’s downward flow with the upward notification to the parent.
Bindable Props: Two-Way Binding
Bindable props ($bindable) enable two-way binding between parent and child:
<!-- ColorPicker.svelte -->
<script lang="ts">
let { color = $bindable('#000000') } = $props()
</script>
<input type="color" bind:value={color} />
<!-- Parent.svelte -->
<script>
import ColorPicker from './ColorPicker.svelte'
let selectedColor = $state('#ff0000')
</script>
<ColorPicker bind:color={selectedColor} />
<p>Selected: {selectedColor}</p> Changes in either direction propagate automatically.
Communication Pattern Summary
Communication Patterns (ASCII Art)
Props: Parent → Child
[Parent] --data--> [Child]
Events: Child → Parent
[Child] --callback--> [Parent]
Bindable: Bidirectional
[Parent] <==bind:value==> [Child]
Context Callback: Deep → Ancestor
[Ancestor: setContext with callback]
^
|
[Deep Descendant: calls context.submit()]
|
v
[Ancestor: callback triggered] Decision Guidance: Communication Patterns
| Need | Best Approach |
|---|---|
| Parent provides data to child | Props |
| Child notifies parent of action | Event (callback prop) |
| Simple two-way binding | Bindable props |
| Deep descendant triggers ancestor | Context with callback |
| Complex form state | Context (form pattern) |
| Simple parent-child form input | Bindable props |
Context vs SvelteKit Load Functions
SvelteKit’s load functions fetch data on the server. Context shares it with components. They’re complementary, not competing.
SvelteKit Load Basics
// +page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
const user = await getUser(locals.session)
const posts = await getPosts(user.id)
return { user, posts }
} <!-- +page.svelte -->
<script>
let { data } = $props()
// data.user, data.posts available via props
</script>
<h1>Welcome, {data.user.name}</h1> Load data arrives via the data prop. This is the standard pattern for route-level data.
Context for Deep Access
When deep descendants need load data without prop drilling:
<!-- +layout.svelte -->
<script>
import { setContext } from 'svelte'
let { data, children } = $props()
// Make user available to all descendants
setContext('user', {
get current() {
return data.user
},
get permissions() {
return data.user.permissions
}
})
</script>
{@render children()} <!-- src/lib/components/UserBadge.svelte (used anywhere) -->
<script>
import { getContext } from 'svelte'
const user = getContext('user')
</script>
<span class="badge">{user.current.name}</span> Data Flow Architecture
Decision Guidance: Load vs Context
| Concern | Best Approach |
|---|---|
| Fetching server data | SvelteKit load |
| Route-specific data | SvelteKit load → props |
| Sharing load data deeply | Load → Context |
| Authentication state | hooks.server.ts + load → Context |
| Client-side only state | Context or stores |
| Global UI state (notifications) | Context |
Why External Libraries Are Rarely Needed
Svelte 5’s built-in tools—$state, $derived, $effect, and context—handle virtually all state management needs elegantly. Unlike some other frameworks, Svelte’s reactivity system is comprehensive enough that external state management libraries add complexity without meaningful benefit for the vast majority of applications.
Svelte 5’s Complete Toolkit
| State Management Need | Svelte 5 Solution |
|---|---|
| Component-scoped state | $state |
| Computed values | $derived |
| Side effects | $effect |
| Tree-scoped shared state | Context API |
| Global shared state | Module-level $state in .svelte.ts files |
| SSR-safe shared state | Context with factory functions |
| Form state | Context (form pattern) |
| Theme/Auth/Locale | Context |
| Data fetching | SvelteKit load functions |
| Client-only globals | Module-level $state (with SSR awareness) |
The Practical Pattern
Most applications need nothing more than this:
<!-- DataProvider.svelte -->
<script>
import { setContext } from 'svelte'
let { children } = $props()
let items = $state([])
let loading = $state(false)
let error = $state(null)
setContext('data', {
get items() {
return items
},
get loading() {
return loading
},
get error() {
return error
},
async load() {
loading = true
error = null
try {
const response = await fetch('/api/items')
items = await response.json()
} catch (e) {
error = e instanceof Error ? e : new Error(String(e))
} finally {
loading = false
}
}
})
</script>
{@render children()} This pattern provides loading states, error handling, and reactive data—all with zero external dependencies.
Adding Caching When Needed
If you need simple caching, extend the pattern:
// lib/context/users.svelte.ts
export class UsersContext {
#users = $state<User[]>([])
#loading = $state(false)
#error = $state<Error | null>(null)
#lastFetched = $state<number>(0)
#cacheTime = 5 * 60 * 1000 // 5 minutes
get users() {
return this.#users
}
get isLoading() {
return this.#loading
}
get error() {
return this.#error
}
async load(forceRefresh = false) {
// Return cached data if fresh
const isCacheValid = Date.now() - this.#lastFetched < this.#cacheTime
if (!forceRefresh && isCacheValid && this.#users.length > 0) {
return
}
this.#loading = true
this.#error = null
try {
const response = await fetch('/api/users')
this.#users = await response.json()
this.#lastFetched = Date.now()
} catch (e) {
this.#error = e instanceof Error ? e : new Error(String(e))
} finally {
this.#loading = false
}
}
invalidate() {
this.#lastFetched = 0
}
} This provides time-based caching, force refresh, and cache invalidation—patterns that cover most real-world needs without external dependencies.
The Bottom Line
Start with Svelte’s built-ins. They are designed to work together seamlessly and handle the state management needs of most applications elegantly. Svelte 5’s runes-based reactivity combined with the Context API is remarkably powerful.
The rare exceptions where external tools might help involve highly specialized requirements like complex finite state machines or real-time collaborative editing—scenarios most applications never encounter.
When in doubt, build with Svelte’s native patterns first. You’ll find they take you further than you might expect.
Hybrid Patterns: Combining Approaches
Real applications often combine multiple patterns strategically. Here’s how they work together.
Context Providing Store-like Access
Combine context’s isolation with store-like ergonomics:
// src/lib/cart/context.ts
import { getContext, setContext } from 'svelte'
const CART_KEY = Symbol('cart')
export function createCartStore() {
let items = $state<CartItem[]>([])
const subtotal = $derived(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
return {
get items() {
return items
},
get subtotal() {
return subtotal
},
get itemCount() {
return items.length
},
addItem(product: Product, quantity = 1) {
const existing = items.find((i) => i.productId === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.push({
productId: product.id,
name: product.name,
price: product.price,
quantity
})
}
},
removeItem(productId: string) {
const index = items.findIndex((i) => i.productId === productId)
if (index !== -1) {
items.splice(index, 1)
}
},
clear() {
items.length = 0
}
}
}
export type CartStore = ReturnType<typeof createCartStore>
export function setCartContext(store: CartStore) {
setContext(CART_KEY, store)
}
export function getCartContext(): CartStore {
return getContext<CartStore>(CART_KEY)
} This gives you:
- Context isolation: Each provider creates its own cart (SSR-safe)
- Store ergonomics: Simple import and use pattern
- Type safety: Full TypeScript support
- Testability: Easy to provide mock implementations
Props for Configuration, Context for Runtime
<!-- CartProvider.svelte -->
<script lang="ts">
import { setCartContext, createCartStore } from './context.js'
import { browser } from '$app/environment'
interface Props {
taxRate?: number
currency?: string
persistKey?: string
children: import('svelte').Snippet
}
let { taxRate = 0.0, currency = 'USD', persistKey, children }: Props = $props()
// Runtime state via context
const cart = createCartStore()
setCartContext(cart)
// Persistence effect if key provided
$effect(() => {
if (persistKey && browser) {
localStorage.setItem(persistKey, JSON.stringify(cart.items))
}
})
</script>
{@render children()} Props configure the provider (static). Context provides the runtime state (dynamic).
Module Constants + Context State
// src/lib/config.ts
// Static configuration as module constants (safe)
export const API_BASE = import.meta.env.VITE_API_URL
export const APP_NAME = 'MyApp'
export const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // src/lib/user/context.ts
// Dynamic, per-request state as context (isolated)
import { API_BASE } from '$lib/config.js'
export function createUserContext(initialUser: User | null) {
let user = $state(initialUser)
return {
get user() {
return user
},
async refresh() {
const response = await fetch(`${API_BASE}/me`)
user = await response.json()
}
}
} Module constants for static configuration. Context for dynamic, user-specific state. Each used where appropriate.
The Decision Framework
Use this flowchart to choose the right approach for any state management need:
Comparison Matrix
Quick reference for pattern selection:
| Feature | Props | Context | Stores | Events | Module State |
|---|---|---|---|---|---|
| Direction | Parent→Child | Ancestor→Descendant | Anywhere | Child→Parent | Anywhere |
| Explicit deps | ✅ Best | ❌ Implicit | ❌ Implicit | ✅ Best | ❌ Implicit |
| Type safety | ✅ Best | ⚠️ Good | ⚠️ Good | ✅ Best | ⚠️ Good |
| Prop drilling | ❌ Required | ✅ Avoided | ✅ Avoided | ❌ Required | ✅ Avoided |
| Subtree scoping | ❌ No | ✅ Yes | ❌ No | N/A | ❌ No |
| SSR safe | ✅ Always | ✅ Always | ⚠️ Careful | ✅ Always | ❌ Dangerous |
| Non-component access | ❌ No | ❌ No | ✅ Yes | ❌ No | ✅ Yes |
| Testability | ✅ Best | ⚠️ Good | ⚠️ Varies | ✅ Best | ❌ Hard |
| Survives unmount | N/A | ❌ No | ✅ Yes | N/A | ✅ Yes |
| IDE support | ✅ Best | ⚠️ Good | ⚠️ Good | ✅ Best | ⚠️ Good |
Real-World Scenarios
E-commerce Application
| State | Pattern | Reasoning |
|---|---|---|
| User authentication | Load → Context | SSR-safe, shared deeply throughout app |
| Shopping cart | Context | SSR-safe, different carts per section possible |
| Product list | Load → Props | Route-specific, explicit component API |
| Theme | Context | Subtree theming possible |
| Toast notifications | Context | UI component renders, triggers everywhere |
| Analytics config | Module state | Truly global, no user data |
Dashboard Application
| State | Pattern | Reasoning |
|---|---|---|
| Auth/permissions | Load → Context | SSR-safe, check everywhere |
| Selected workspace | Context | Different per route section |
| Widget data | Load per widget | Route-based, isolated |
| User preferences | Context | Subtree application |
| Real-time updates | Store (client-only) | WebSocket handler needs access |
Form-Heavy Application
| State | Pattern | Reasoning |
|---|---|---|
| Form values | Context | Many inputs, centralized management |
| Validation errors | Context | Shared with all fields |
| Field-level state | $state | Component local |
| Submit handler | Context callback | Called from deep submit button |
| Dirty tracking | $derived in context | Computed from fields |
Common Mistakes and Anti-Patterns
Mistake 1: Choosing by familiarity, not fit
Don’t use stores everywhere because you’re comfortable with them. Don’t use context for everything because you read this series. Match the pattern to the problem.
Mistake 2: Module state with user data
// ❌ This will cause cross-request data leaks in SSR
let currentUser = $state(null) If data varies by user, it cannot be module-level in SSR apps.
Mistake 3: Context for direct parent-child
<!-- ❌ Over-engineered -->
<Parent>
<!-- setContext -->
<Child />
<!-- getContext -->
</Parent> Props are simpler, clearer, and better typed for direct relationships.
Mistake 4: Stores for subtree-scoped data
// ❌ Can't have different themes in different sections
// src/lib/theme.ts
let theme = $state('light') Module-level state is global. If different parts of the app need different values, use context.
Mistake 5: Props through 5+ levels
<!-- ❌ Prop drilling pain -->
<A {data}>
<B {data}>
<C {data}>
<D {data}>
<E {data} />
</D>
</C>
</B>
</A> Context eliminates this coupling. Don’t torture your intermediate components.
Mistake 6: Ignoring SSR concerns
// ❌ Works in dev, breaks in production
export let globalState = $state({ user: null }) Always consider SSR safety when choosing patterns for shared state.
Pattern Selection Quick Reference
Use this matrix for quick decisions:
| Scenario | Recommended Pattern |
|---|---|
| Direct parent → child data | Props |
| Skip 2+ levels | Context |
| Ambient data (theme, auth, locale) | Context |
| Compound components (Tabs/Tab) | Context |
| Different values in different subtrees | Context |
| Truly global singleton (same for all users) | Module state |
| Client-only global state | Store (with caution) |
| SSR app with user data | Context |
| Sibling communication | Lift state + props |
| Cross-tree communication (notifications) | Store (client-only) or Context (SSR) |
| Static configuration | Module constants |
| Child notifies parent | Events (callback props) |
| Two-way parent-child binding | Bindable props |
| Server-fetched route data | SvelteKit Load |
| Deep access to load data | Load → Context |
Conclusion
The question isn’t which pattern is best—it’s which pattern fits each situation. Each state management approach in Svelte 5 excels in specific scenarios:
Props provide explicit, type-safe parent-child communication. They’re the foundation of component APIs and should be your default for direct relationships.
Context shines when you need to skip intermediate components or scope different values to different subtrees. It’s SSR-safe by design and perfect for ambient data like themes, authentication, and configuration.
Stores work for truly global state that non-component code needs to access. Use them carefully in SSR applications, preferring the context-wrapped factory pattern.
Events (callback props) communicate upward from children to parents. Combine them with context for deep-to-ancestor communication patterns.
Module state is appropriate only for genuine singletons that don’t vary by user or request. Never use it for user-specific data in SSR applications.
SvelteKit Load fetches route data on the server. Combine it with context to share that data deeply without prop drilling.
The most maintainable applications use multiple patterns in combination, choosing the right tool for each job. Start simple with $state and props, then reach for context when you hit prop drilling. Reserve stores for truly global client-side state, and always consider SSR safety in your architecture decisions.
Key Takeaways
Props — Best for direct parent-child, explicit APIs, maximum type safety. Default choice for 1-2 levels.
Context — Best for skipping levels, subtree scoping, SSR safety, and internal compound component state. Use when props drill through 3+ levels.
Stores — Best for truly global state, non-component access, and data that survives unmounts. Be careful with SSR.
Events — Best for child-to-parent notification. Combine with context callbacks for deep communication.
Module State — Only for genuine singletons (config, analytics). Never for user data in SSR apps.
SvelteKit Load — Best for server-fetched route data. Combine with context for deep access.
The best applications combine approaches: load data with SvelteKit, share it deeply with context, use props for explicit component APIs, events for child-to-parent actions, and reserve stores for truly global non-SSR state.
What’s Next
Put context into practice with Theme Management with Context, building a complete theme system with light/dark modes, system preference detection, and CSS custom properties.
See Also
Official Documentation
- Svelte Context — Official guide
- Svelte Stores — Store documentation
- SvelteKit Load — Data loading
Related Articles
- Context Best Practices — Professional patterns
- Context Pitfalls — Common mistakes
- Making Context Reactive — Reactivity patterns
- Class-Based Context — Encapsulated context