From Mechanics to Mastery
Throughout this series, we’ve explored the mechanics of Svelte’s Context API—how to provide context, consume it, handle deep nesting, make it reactive with runes, and apply it to real-world scenarios like theme management and complex data sharing. You now understand how context works.
This article shifts focus to how to use context well. We’ll establish professional patterns that make your context code maintainable, testable, and scalable. These aren’t arbitrary style preferences—they’re lessons learned from building and maintaining large Svelte applications where context plays a central architectural role.
By following these practices, you’ll write context code that:
- Is immediately understandable to other developers
- Catches errors at compile time rather than runtime
- Can be tested in isolation
- Performs well even in complex applications
- Scales gracefully as your application grows
Whether you’re a beginner looking to establish good habits from the start, or an experienced developer seeking to refine your patterns, this guide provides the foundation for professional context usage.
The Decision Framework
Before diving into implementation details, you need a mental model for deciding when to use context versus other patterns. This decision framework will save you from the common mistake of over-engineering simple problems or under-engineering complex ones.
┌─────────────────────────────────────────────────────────────────┐
│ State/Data Decision Tree │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Q1: Is it needed by just one child component? │
│ YES → Use props │
│ NO → Continue to Q2 │
│ │
│ Q2: Is it needed by siblings (not ancestors/descendants)? │
│ YES → Lift state to common ancestor, use props │
│ NO → Continue to Q3 │
│ │
│ Q3: Is it needed by deeply nested descendants? │
│ YES → Continue to Q4 │
│ NO → Use props (probably shallow) │
│ │
│ Q4: Is it "ambient" data (theme, locale, auth, tenant)? │
│ YES → Use context │
│ NO → Continue to Q5 │
│ │
│ Q5: Is it feature-boundary state (cart, form wizard)? │
│ YES → Use context with provider component │
│ NO → Continue to Q6 │
│ │
│ Q6: Is it truly global (needs to work outside component tree)?│
│ YES → Consider stores (but verify not SSR-unsafe) │
│ NO → Context is probably right │
│ │
└─────────────────────────────────────────────────────────────────┘ This framework encapsulates the core insight: context fills a specific niche—subtree-scoped shared state—and excels when used appropriately. It’s not a replacement for props, stores, or load functions, but a complement to them.
Naming Conventions
Consistent naming makes code predictable. When developers can guess function and variable names correctly, they spend less time reading documentation and searching codebases. This section establishes the naming patterns that professional Svelte codebases follow.
Context Key Naming
If using string keys, adopt a consistent naming pattern across your entire application:
// ✅ Good: Descriptive, lowercase with hyphens
setContext('user-auth', authContext)
setContext('shopping-cart', cartContext)
setContext('theme-settings', themeContext)
setContext('notification-manager', notificationContext)
// ✅ Good: Namespaced for libraries or large apps
setContext('myapp:auth', authContext)
setContext('myapp:cart', cartContext)
setContext('acme-ui:modal', modalContext)
// ❌ Avoid: Vague or inconsistent
setContext('data', authContext) // Too vague—data for what?
setContext('UserAuth', authContext) // Inconsistent casing
setContext('ctx', cartContext) // Cryptic abbreviation
setContext('Theme', themeContext) // PascalCase is for components
setContext('THEME', themeContext) // SCREAMING_CASE is confusing For production applications, prefer Symbols or createContext to eliminate collision risks entirely. String keys work fine for small applications, but Symbol keys guarantee uniqueness—no two Symbols are ever equal, even with the same description:
// ✅ Best: Symbols for guaranteed uniqueness
const AUTH_KEY = Symbol('auth')
const CART_KEY = Symbol('cart')
// ✅ Best: createContext for type safety (Svelte 5.40+)
const [getAuth, setAuth] = createContext<AuthContext>() Context Key Stability
Context keys must be stable across your application’s lifetime. Changing keys breaks consumers, sometimes silently. Consider this problematic pattern:
// ❌ Key could change between versions
setContext('themeV2', theme) // Was 'theme' in v1
// Every consumer needs to update:
getContext('themeV2') // Breaking change! Instead, centralize keys and hide them behind accessor functions:
// src/lib/context-keys.ts
export const THEME_KEY = Symbol('theme')
export const AUTH_KEY = Symbol('auth')
export const CART_KEY = Symbol('cart') Or better, provide typed accessor functions that hide the key entirely:
// src/lib/theme/context.ts
import { getContext, setContext, hasContext } from 'svelte'
const KEY = Symbol('theme')
export interface ThemeContext {
/* ... */
}
export function setThemeContext(ctx: ThemeContext): void {
setContext(KEY, ctx)
}
export function getThemeContext(): ThemeContext {
return getContext<ThemeContext>(KEY)
} Now consumers never touch the key directly, and you can change the implementation without breaking anyone:
<script>
import { getThemeContext } from '$lib/theme/context.js'
const theme = getThemeContext()
</script> This pattern hides the key implementation, provides type safety, allows key changes without breaking consumers, and documents the expected return type.
Function Naming Patterns
Establish consistent naming for context-related functions and stick with it across your entire codebase:
// Creation functions: create[Name]Context
export function createAuthContext(options: AuthOptions): AuthContext
export function createCartContext(initialItems?: CartItem[]): CartContext
export function createThemeContext(defaultTheme?: ThemeName): ThemeContext
// Getter functions: get[Name]Context
export function getAuthContext(): AuthContext
export function getCartContext(): CartContext
// Alternative: use* prefix (familiar to React developers, works well in Svelte)
export function useAuth(): AuthContext
export function useCart(): CartContext
export function useTheme(): ThemeContext
// Checker functions: has[Name]Context
export function hasAuthContext(): boolean
export function hasCartContext(): boolean Pick one pattern and maintain consistency. Mixing getAuthContext with useCart creates cognitive friction and makes your codebase harder to navigate.
File Naming Patterns
Name files to reflect their contents and indicate whether they use runes:
lib/context/
├── auth.svelte.ts # Auth context (uses runes)
├── auth.ts # Auth context (no runes needed)
├── cart.svelte.ts # Cart context with reactive state
├── theme.svelte.ts # Theme context with effects
├── types.ts # Shared types for all contexts
└── index.ts # Public exports Use the .svelte.ts extension when the file uses runes ($state, $derived, $effect). This isn’t just convention—Svelte requires it for rune processing. Getting this wrong leads to cryptic errors that waste debugging time.
Provider Component Naming
Provider components should clearly indicate what they provide:
// ✅ Good: Clear purpose
AuthProvider.svelte
CartProvider.svelte
ThemeProvider.svelte
NotificationProvider.svelte
// ✅ Good: Composition component
AppProviders.svelte // Composes multiple providers
Providers.svelte // Alternative shorter name
// ❌ Avoid: Unclear names
Provider.svelte // Which provider?
Context.svelte // Too generic
Wrapper.svelte // Doesn't indicate purpose Type Names
Follow TypeScript conventions for type naming:
// ✅ Type naming conventions
interface ThemeContext {
/* ... */
}
interface AuthContext {
/* ... */
}
interface CartContext {
/* ... */
}
// Alternative: "*State" for internal state, "*Context" for exposed API
interface ThemeState {
/* ... */
} // Internal implementation
interface ThemeContext {
/* ... */
} // What consumers receive File Organization
How you organize context files affects discoverability and maintainability. The right choice depends on your application size, team structure, and how your contexts relate to features.
Pattern 1: Centralized Context Directory
All context in one location:
src/lib/
└── context/
├── auth.svelte.ts
├── cart.svelte.ts
├── theme.svelte.ts
├── notifications.svelte.ts
├── types.ts
└── index.ts This pattern offers several advantages. All contexts are easy to find in one place, and imports become simple and consistent. You get a clear overview of application-wide state, making it good for smaller to medium applications where contexts are truly shared across features.
The index.ts file exports everything consumers need:
// lib/context/index.ts
// Auth context
export {
createAuthContext,
getAuthContext,
hasAuthContext,
type AuthContext,
type User
} from './auth.svelte'
// Cart context
export {
createCartContext,
getCartContext,
hasCartContext,
type CartContext,
type CartItem
} from './cart.svelte'
// Theme context
export {
createThemeContext,
getThemeContext,
hasThemeContext,
type ThemeContext,
type ThemeMode
} from './theme.svelte'
// Re-export all types
export * from './types' Pattern 2: Feature-Colocated Context
Context lives with its feature:
src/lib/features/
├── auth/
│ ├── context.svelte.ts # Auth context
│ ├── AuthProvider.svelte # Provider component
│ ├── LoginForm.svelte # Feature components
│ ├── LogoutButton.svelte
│ ├── UserMenu.svelte
│ └── index.ts # Public exports
├── cart/
│ ├── context.svelte.ts
│ ├── CartProvider.svelte
│ ├── CartDrawer.svelte
│ ├── CartIcon.svelte
│ ├── AddToCartButton.svelte
│ └── index.ts
└── theme/
├── context.svelte.ts
├── ThemeProvider.svelte
├── ThemeToggle.svelte
└── index.ts This pattern keeps context close to its consumers, making features self-contained and portable. Clear ownership boundaries emerge naturally. It scales well for large applications and teams, and makes it easy to understand what a feature needs at a glance.
Each feature exposes a clean public API through its index.ts:
// lib/features/auth/index.ts
// Context (internal implementation hidden)
export { createAuthContext, getAuthContext } from './context.svelte'
export type { AuthContext, User } from './context.svelte'
// Provider
export { default as AuthProvider } from './AuthProvider.svelte'
// Components
export { default as LoginForm } from './LoginForm.svelte'
export { default as LogoutButton } from './LogoutButton.svelte'
export { default as UserMenu } from './UserMenu.svelte' Pattern 3: Hybrid Approach
Core/shared context centralized, feature-specific context colocated:
src/lib/
├── context/ # Application-wide context
│ ├── theme.svelte.ts # Everyone needs theme
│ ├── locale.svelte.ts # Internationalization
│ ├── feature-flags.svelte.ts
│ └── index.ts
└── features/
├── auth/ # Feature-specific context
│ ├── context.svelte.ts
│ └── ...
├── cart/
│ ├── context.svelte.ts
│ └── ...
└── checkout/
├── context.svelte.ts # Only used in checkout flow
└── ... Use this decision guide to choose the right location:
| Context Type | Location | Reasoning |
|---|---|---|
| Theme, locale, feature flags | Centralized | Used everywhere across the application |
| Auth | Either | Depends on how auth-specific your components are |
| Shopping cart | Feature | Cart logic is feature-specific |
| Checkout wizard | Feature | Only used in checkout flow |
| Modal/toast managers | Centralized | Used from many features |
Type Safety
TypeScript transforms context from a runtime mystery to a compile-time contract. Invest in proper typing—it pays dividends in developer experience and bug prevention every single day.
Define Explicit Interfaces
Never rely on type inference for context APIs. Explicit interfaces serve as documentation, enable better IDE support, and catch errors early:
// ✅ Good: Explicit interface
export interface AuthContext {
readonly user: User | null
readonly isAuthenticated: boolean
readonly isLoading: boolean
readonly error: string | null
login(email: string, password: string): Promise<void>
logout(): Promise<void>
refresh(): Promise<void>
}
export interface User {
id: string
email: string
name: string
avatar?: string
role: 'admin' | 'user' | 'guest'
permissions: string[]
}
// ❌ Avoid: Implicit types
export function createAuthContext() {
let user = $state(null) // What's the type of user?
// ...
return {
get user() {
return user
},
login(email, password) {
/* ... */
} // What are the param types?
}
} The explicit interface version tells developers exactly what they’re working with before they ever read the implementation.
Use createContext for Type-Safe Access
Svelte 5.40+ provides createContext for automatic type safety:
// lib/context/auth.svelte.ts
import { createContext } from 'svelte'
export interface AuthContext {
readonly user: User | null
readonly isAuthenticated: boolean
login(email: string, password: string): Promise<void>
logout(): Promise<void>
}
// Type is automatically inferred for both getter and setter
export const [getAuth, setAuth] = createContext<AuthContext>() Consumers get full IntelliSense with zero extra effort:
<script lang="ts">
import { getAuth } from '$lib/context/auth.svelte'
const auth = getAuth()
auth.user?.name // ✅ TypeScript knows this is string | undefined
auth.isAuthenticated // ✅ TypeScript knows this is boolean
auth.login('a', 'b') // ✅ TypeScript validates parameters
auth.foo // ❌ TypeScript error: Property 'foo' does not exist
</script> Typed Wrapper Functions
If not using createContext, create typed wrapper functions that provide the same benefits:
// lib/context/auth.svelte.ts
import { setContext, getContext, hasContext } from 'svelte'
const AUTH_KEY = Symbol('auth')
export interface AuthContext {
readonly user: User | null
readonly isAuthenticated: boolean
login(email: string, password: string): Promise<void>
logout(): Promise<void>
}
export function createAuthContext(): AuthContext {
let user = $state<User | null>(null)
let isLoading = $state(false)
const context: AuthContext = {
get user() {
return user
},
get isAuthenticated() {
return user !== null
},
async login(email: string, password: string) {
isLoading = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
user = await response.json()
} finally {
isLoading = false
}
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST' })
user = null
}
}
return setContext(AUTH_KEY, context)
}
// Typed getter - no casting needed at call sites
export function getAuthContext(): AuthContext {
const context = getContext<AuthContext>(AUTH_KEY)
if (!context) {
throw new Error('Auth context not found. Wrap your app in AuthProvider.')
}
return context
}
export function hasAuthContext(): boolean {
return hasContext(AUTH_KEY)
} Generic Context Utilities
Create reusable utilities for common patterns to reduce boilerplate:
// lib/context/utils.ts
import { setContext, getContext, hasContext } from 'svelte'
/**
* Creates a type-safe context with getter, setter, and checker.
* Alternative to createContext for more control.
*/
export function defineContext<T>(name: string) {
const key = Symbol(name)
return {
set(value: T): T {
return setContext(key, value)
},
get(): T {
if (!hasContext(key)) {
throw new Error(
`${name} context not found. ` +
`Ensure this component is wrapped in the appropriate provider.`
)
}
return getContext<T>(key)
},
getOptional(): T | undefined {
return hasContext(key) ? getContext<T>(key) : undefined
},
has(): boolean {
return hasContext(key)
}
}
} Usage becomes clean and consistent:
// lib/context/cart.svelte.ts
import { defineContext } from './utils'
export interface CartContext {
readonly items: CartItem[]
readonly total: number
addItem(product: Product, quantity?: number): void
removeItem(productId: string): void
clear(): void
}
const cartContext = defineContext<CartContext>('Cart')
export const setCartContext = cartContext.set
export const getCartContext = cartContext.get
export const hasCartContext = cartContext.has API Design Principles
A well-designed context API makes consumers’ lives easier and prevents misuse. These principles apply whether you’re building context for your own application or a shared library.
Expose Getters, Not Raw State
Always use getters to expose state. This provides encapsulation, ensures consumers receive fresh reactive values, and enables future refactoring:
// ❌ Exposing raw state - consumers get a snapshot, not reactive
let count = $state(0)
setContext('counter', count)
// ❌ Exposing state object directly - consumers can mutate arbitrarily
let state = $state({ count: 0, step: 1 })
setContext('counter', state)
// ✅ Exposing via getters - controlled, reactive access
let count = $state(0)
let step = $state(1)
setContext('counter', {
get count() {
return count
},
get step() {
return step
},
increment() {
count += step
},
decrement() {
count -= step
}
}) Getters ensure fresh values, allow future refactoring without breaking consumers, and enable validation or transformation before returning values.
Provide Actions, Not Just State
Good APIs include both state and the operations that modify it. Don’t make consumers figure out how to update state correctly:
// ❌ State only - consumers must figure out how to update
setContext('user', {
get data() {
return user
}
})
// ✅ State + actions - clear API for updates
setContext('user', {
get data() {
return user
},
get isAuthenticated() {
return user !== null
},
async updateProfile(updates: Partial<User>) {
const response = await fetch('/api/user', {
method: 'PATCH',
body: JSON.stringify(updates)
})
user = await response.json()
},
async uploadAvatar(file: File) {
const formData = new FormData()
formData.append('avatar', file)
const response = await fetch('/api/user/avatar', {
method: 'POST',
body: formData
})
const { avatarUrl } = await response.json()
user = { ...user, avatar: avatarUrl }
}
}) Include Loading and Error States
Async operations should expose their status. Consumers shouldn’t have to track loading and error states themselves:
interface DataContext<T> {
readonly data: T | null
readonly isLoading: boolean
readonly error: Error | null
readonly isError: boolean
readonly isSuccess: boolean
refresh(): Promise<void>
}
function createDataContext<T>(fetcher: () => Promise<T>): DataContext<T> {
let data = $state<T | null>(null)
let isLoading = $state(true)
let error = $state<Error | null>(null)
async function load() {
isLoading = true
error = null
try {
data = await fetcher()
} catch (e) {
error = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading = false
}
}
// Initial load
load()
return setContext('data', {
get data() {
return data
},
get isLoading() {
return isLoading
},
get error() {
return error
},
get isError() {
return error !== null
},
get isSuccess() {
return data !== null && !error
},
refresh: load
})
} Use Result Types for Fallible Operations
For operations that can fail, return result objects instead of throwing. This makes error handling explicit and composable:
interface Result<T, E = Error> {
success: boolean
data?: T
error?: E
}
interface CartContext {
// ...state getters...
addItem(product: Product, quantity?: number): Result<CartItem>
removeItem(productId: string): Result<void>
applyDiscount(code: string): Promise<Result<Discount, DiscountError>>
}
function createCartContext(): CartContext {
let items = $state<CartItem[]>([])
return setContext('cart', {
get items() {
return items
},
addItem(product, quantity = 1) {
// Validate stock
if (quantity > product.stockCount) {
return {
success: false,
error: new Error(`Only ${product.stockCount} items available`)
}
}
// Add item
const item = { ...product, quantity }
items.push(item)
return { success: true, data: item }
},
async applyDiscount(code) {
const response = await fetch(`/api/discounts/${code}`)
if (!response.ok) {
const error = await response.json()
return {
success: false,
error: { code: 'INVALID_CODE', message: error.message }
}
}
const discount = await response.json()
return { success: true, data: discount }
}
})
} Consumers handle results explicitly, making error cases visible:
<script>
const cart = getCartContext()
async function handleAddToCart() {
const result = cart.addItem(product)
if (result.success) {
toast.success(`Added ${product.name} to cart`)
} else {
toast.error(result.error.message)
}
}
</script> Keep APIs Minimal and Stable
Start small. You can always add to a context API, but removing is a breaking change:
// ✅ Start minimal
interface CartContext {
readonly items: CartItem[]
readonly total: number
addItem: (product: Product) => void
removeItem: (id: string) => void
}
// Later, add features additively
interface CartContext {
// ... existing properties unchanged ...
applyCoupon: (code: string) => Promise<boolean> // New feature!
} Performance Considerations
Context is designed to be performant, but poor usage patterns can still cause issues. Understanding these patterns helps you avoid performance pitfalls before they become problems.
Measure Before Optimizing
Context lookups are fast—typically microseconds. Before applying optimization patterns, verify you actually have a performance problem:
// Simple timing for context-heavy operations
console.time('render-cycle')
// ... component renders
console.timeEnd('render-cycle')
// Use browser DevTools Performance tab for detailed analysis
// Look for long tasks, excessive re-renders, or memory growth Most applications never need context-specific optimizations. The patterns below address edge cases—apply them only when profiling reveals context as an actual bottleneck, not as premature optimization.
Avoid Frequent Context Updates
Context lookups happen once during component initialization. However, if your context value changes frequently and many components read it, you may see performance impacts:
// ❌ Potentially problematic: Updates every mouse move
function createMouseContext() {
let position = $state({ x: 0, y: 0 })
$effect(() => {
function handleMove(e: MouseEvent) {
position.x = e.clientX
position.y = e.clientY
}
window.addEventListener('mousemove', handleMove)
return () => window.removeEventListener('mousemove', handleMove)
})
return setContext('mouse', {
get position() {
return position
}
})
} If many components consume this context, each will react to every mouse move. The solution is to throttle updates or split the context:
<!-- MouseProvider.svelte -->
<script>
import { setContext } from 'svelte'
import { throttle } from 'lodash-es'
let { children } = $props()
let position = $state({ x: 0, y: 0 })
$effect(() => {
const handleMove = throttle((e) => {
position.x = e.clientX
position.y = e.clientY
}, 16) // ~60fps
window.addEventListener('mousemove', handleMove)
return () => {
handleMove.cancel()
window.removeEventListener('mousemove', handleMove)
}
})
setContext('mouse', {
get position() {
return position
}
})
</script>
{@render children()} When $effect Can Assign StateThe
$effectrune generally shouldn’t assign state—that’s what$derivedis for. However, external event handlers (DOM events, timers, WebSocket messages) are legitimate exceptions. These events originate outside Svelte’s reactivity system and must push values into state.The mouse tracking example above is valid because:
- Mouse movements are external DOM events, not derived from other state
- There’s no way to express “current mouse position” as a
$derivedvalue- The effect properly cleans up its event listener
When you see
$effectassigning state, ask: “Is this responding to something external?” If yes, it’s appropriate. If the value could be computed from other reactive state, use$derivedinstead.
Split Large Contexts
If you have a large context object where different components need different parts, consider splitting it:
// ❌ Monolithic context: All consumers react to any change
setContext('app', {
get user() {
return user
},
get theme() {
return theme
},
get cart() {
return cart
},
get notifications() {
return notifications
}
// ... many more properties
})
// ✅ Split contexts: Components only subscribe to what they need
setContext('user', {
get data() {
return user
}
})
setContext('theme', {
get mode() {
return theme
}
})
setContext('cart', {
get items() {
return cartItems
}
})
setContext('notifications', {
get list() {
return notifications
}
}) Use $derived for Computed Values
Compute derived values once in the provider, not repeatedly in consumers:
// ✅ Good: Compute once in provider
function createCartContext() {
let items = $state<CartItem[]>([])
// Computed once, cached until items change
let subtotal = $derived(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
let tax = $derived(subtotal * 0.08)
let total = $derived(subtotal + tax)
return setContext('cart', {
get items() {
return items
},
get subtotal() {
return subtotal
},
get tax() {
return tax
},
get total() {
return total
}
})
}
// ❌ Avoid: Consumers compute the same value repeatedly
// In Consumer1.svelte
const cart = getCartContext()
let total = $derived(cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0) * 1.08)
// In Consumer2.svelte - same computation again!
const cart = getCartContext()
let total = $derived(cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0) * 1.08) Lazy Initialization
For expensive operations, initialize lazily:
function createApiContext() {
// Don't create client immediately
let client: ApiClient | null = null
function getClient(): ApiClient {
// Create on first access
if (!client) {
client = new ApiClient({
baseUrl: '/api',
timeout: 10000
})
}
return client
}
return setContext('api', {
get client() {
return getClient()
}
})
} Testing Context
Well-designed context is testable context. The patterns in this section ensure your context logic can be tested in isolation and that components consuming context can be tested effectively.
Testing Context Logic in Isolation
Extract context logic into .svelte.ts files so you can test it without rendering components:
// lib/context/counter.svelte.ts
export interface CounterContext {
readonly value: number
readonly isAtMax: boolean
readonly isAtMin: boolean
increment(): void
decrement(): void
reset(): void
}
export function createCounter(initial = 0, min = 0, max = 100): CounterContext {
let value = $state(initial)
return {
get value() {
return value
},
get isAtMax() {
return value >= max
},
get isAtMin() {
return value <= min
},
increment() {
if (value < max) value++
},
decrement() {
if (value > min) value--
},
reset() {
value = initial
}
}
} Test the logic directly:
// lib/context/counter.svelte.test.ts
import { describe, it, expect } from 'vitest'
import { createCounter } from './counter.svelte'
describe('createCounter', () => {
it('initializes with default value', () => {
const counter = createCounter()
expect(counter.value).toBe(0)
})
it('initializes with custom value', () => {
const counter = createCounter(50)
expect(counter.value).toBe(50)
})
it('increments value', () => {
const counter = createCounter(0)
counter.increment()
expect(counter.value).toBe(1)
})
it('respects max boundary', () => {
const counter = createCounter(99, 0, 100)
counter.increment()
expect(counter.value).toBe(100)
counter.increment()
expect(counter.value).toBe(100) // Stays at max
expect(counter.isAtMax).toBe(true)
})
it('respects min boundary', () => {
const counter = createCounter(1, 0, 100)
counter.decrement()
expect(counter.value).toBe(0)
counter.decrement()
expect(counter.value).toBe(0) // Stays at min
expect(counter.isAtMin).toBe(true)
})
it('resets to initial value', () => {
const counter = createCounter(25)
counter.increment()
counter.increment()
expect(counter.value).toBe(27)
counter.reset()
expect(counter.value).toBe(25)
})
}) Testing Provider Components
Test providers by rendering them with test consumers:
// lib/context/CounterProvider.test.ts
import { render, screen } from '@testing-library/svelte'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import CounterTestWrapper from './CounterTestWrapper.svelte'
describe('CounterProvider', () => {
it('provides counter context to children', async () => {
render(CounterTestWrapper)
expect(screen.getByTestId('value')).toHaveTextContent('0')
await userEvent.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByTestId('value')).toHaveTextContent('1')
})
it('respects initial prop', () => {
render(CounterTestWrapper, { props: { initial: 42 } })
expect(screen.getByTestId('value')).toHaveTextContent('42')
})
}) <!-- CounterTestWrapper.svelte -->
<script>
import CounterProvider from './CounterProvider.svelte'
import CounterConsumer from './CounterConsumer.test.svelte'
let { initial = 0 } = $props()
</script>
<CounterProvider {initial}>
<CounterConsumer />
</CounterProvider> <!-- CounterConsumer.test.svelte -->
<script>
import { getCounterContext } from './counter.svelte'
const counter = getCounterContext()
</script>
<span data-testid="value">{counter.value}</span>
<button onclick={counter.increment}>Increment</button>
<button onclick={counter.decrement}>Decrement</button> Mocking Context for Unit Tests
Sometimes you want to test a component with a mock context instead of the real provider:
// lib/context/test-utils.ts
import { setContext } from 'svelte'
import { vi } from 'vitest'
const AUTH_KEY = Symbol('auth')
export function mockAuthContext(overrides: Partial<AuthContext> = {}): AuthContext {
const defaultContext: AuthContext = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: vi.fn(),
logout: vi.fn(),
refresh: vi.fn()
}
const context = { ...defaultContext, ...overrides }
setContext(AUTH_KEY, context)
return context
} <!-- MockProvider.svelte for testing -->
<script lang="ts">
import { mockAuthContext } from '$lib/context/test-utils'
import type { Snippet } from 'svelte'
import type { User } from '$lib/context/auth.svelte'
interface Props {
user?: User | null
isAuthenticated?: boolean
children: Snippet
}
let { user = null, isAuthenticated = false, children }: Props = $props()
// Set context once at initialization.
// Test utilities don't need reactive prop updates.
mockAuthContext({ user, isAuthenticated })
</script>
{@render children()} Create a Generic Test Wrapper
For testing multiple contexts at once:
<!-- tests/ContextWrapper.svelte -->
<script lang="ts">
import { setContext } from 'svelte'
import type { Snippet } from 'svelte'
interface Props {
contexts: Record<string | symbol, unknown>
children: Snippet
}
const props: Props = $props()
// Context must be set during component initialization.
// For test utilities, props don't change after mount.
for (const [key, value] of Object.entries(props.contexts)) {
setContext(key, value)
}
</script>
{@render props.children()} // tests/AddToCart.test.ts
import { render, screen } from '@testing-library/svelte'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import ContextWrapper from './ContextWrapper.svelte'
import AddToCart from '$lib/cart/AddToCart.svelte'
import { createMockCartContext } from './helpers'
test('calls addItem when clicked', async () => {
const mockCart = createMockCartContext()
const { getByRole } = render(ContextWrapper, {
props: {
contexts: { cart: mockCart }
}
})
await userEvent.click(getByRole('button'))
expect(mockCart.addItem).toHaveBeenCalled()
}) Documentation Practices
Good documentation makes context usable by developers who didn’t write it—including future you.
JSDoc Comments
Document all public functions and interfaces with examples:
/**
* Authentication context providing user state and auth operations.
*
* @example
* ```svelte
* <script>
* import { getAuthContext } from '$lib/context/auth.svelte'
*
* const auth = getAuthContext()
* </script>
*
* {#if auth.isAuthenticated}
* <p>Welcome, {auth.user.name}!</p>
* {:else}
* <LoginForm />
* {/if}
* ```
*/
export interface AuthContext {
/** The currently authenticated user, or null if not logged in */
readonly user: User | null
/** Whether a user is currently authenticated */
readonly isAuthenticated: boolean
/** Whether an authentication operation is in progress */
readonly isLoading: boolean
/** The most recent authentication error, if any */
readonly error: string | null
/**
* Authenticates a user with email and password.
*
* @param email - User's email address
* @param password - User's password
* @throws {AuthError} If credentials are invalid
*
* @example
* ```typescript
* try {
* await auth.login('user@example.com', 'password123')
* goto('/dashboard')
* } catch (e) {
* console.error('Login failed:', e.message)
* }
* ```
*/
login(email: string, password: string): Promise<void>
/**
* Logs out the current user and clears session data.
*/
logout(): Promise<void>
} README for Context Directories
For larger applications, include a README in your context directory that documents available contexts, their providers, consumers, and composition requirements. This becomes the entry point for developers new to your codebase.
Common Anti-Patterns
Understanding what not to do is as important as knowing best practices. These anti-patterns are common traps that waste development time and create maintenance headaches.
1: Context for Every Prop
Don’t use context just to avoid writing prop={value}. Props are explicit and self-documenting:
<!-- ❌ Over-engineered -->
<!-- Parent.svelte -->
<script>
import { setContext } from 'svelte'
setContext('message', 'Hello')
</script>
<Child />
<!-- Child.svelte -->
<script>
import { getContext } from 'svelte'
const message = getContext('message')
</script>
<p>{message}</p> <!-- ✅ Just use props -->
<Child message="Hello" /> 2: Huge Monolithic Contexts
Don’t put everything in one context. Split by feature/domain:
// ❌ Everything in one blob
setContext('app', {
user,
theme,
cart,
notifications,
settings,
permissions
})
// ✅ Separate concerns
setContext('auth', authContext)
setContext('theme', themeContext)
setContext('cart', cartContext) 3: Deeply Nested Provider Soup
<!-- ❌ Provider soup -->
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
<CartProvider>
<NotificationProvider>
<FeatureFlagProvider>
<App />
</FeatureFlagProvider>
</NotificationProvider>
</CartProvider>
</LocaleProvider>
</ThemeProvider>
</AuthProvider> Consolidate into one composed provider:
<!-- ✅ Single provider that sets up everything -->
<AppProvider>
<App />
</AppProvider> 4: Context for Sibling Communication
Context flows down, not sideways. For sibling communication, lift state to their common ancestor.
5: Recreating Context Objects Every Render
<script>
// ❌ New object every time
setContext('config', {
apiUrl: '/api',
timeout: 5000
})
</script> <script>
// ✅ Stable reference
const config = {
apiUrl: '/api',
timeout: 5000
}
setContext('config', config)
</script> 6: Context for Static Configuration
<script>
// ❌ Static data doesn't need context
setContext('apiUrl', 'https://api.example.com')
setContext('appName', 'My App')
</script> Use environment variables or constants instead:
// ✅ Configuration as constants
// src/lib/config.ts
export const API_URL = import.meta.env.VITE_API_URL
export const APP_NAME = 'My App' 7: Context for Styling
<script>
// ❌ Styling through context
setContext('buttonColor', '#3b82f6')
setContext('buttonRadius', '0.5rem')
</script> CSS custom properties handle styling better:
<!-- ✅ CSS for styling -->
<div
style="
--button-color: #3b82f6;
--button-radius: 0.5rem;
"
>
<Button />
</div> When NOT to Use Context
Context is powerful but not universally appropriate. Knowing when to avoid it is as important as knowing when to use it.
Use Props When…
Props are the right choice for direct parent-child communication, when the component API should be explicit, or when you want maximum TypeScript support:
<!-- Props are clearer for direct relationships -->
<Card title="Welcome" variant="primary">
<p>Card content</p>
</Card>
<!-- Consumers should see exactly what's needed -->
<DatePicker value={selectedDate} minDate={today} maxDate={nextYear} onchange={handleChange} /> Use Stores When…
Stores are better when state is truly global (singleton), needs to persist across component unmounts, or when non-component code needs access:
// A single toast queue for the entire app
export const toasts = writable<Toast[]>([])
// API client needs auth token - stores work anywhere
import { authStore } from '$lib/stores/auth'
export async function apiRequest(path: string) {
const { token } = get(authStore)
return fetch(path, {
headers: { Authorization: `Bearer ${token}` }
})
} Use SvelteKit Load Functions When…
Load functions are the right choice when data comes from the server, different routes need different data, or you need SSR-safe data fetching:
// +page.server.ts - SvelteKit handles this
export const load: PageServerLoad = async ({ locals }) => {
const user = await getUser(locals.session)
return { user }
} Quick Reference Matrix
| Need | Solution |
|---|---|
| Pass data one level down | Props |
| Pass data through 3+ levels | Context |
| Component library internal state | Context |
| Application-wide singleton | Store |
| Route-specific server data | SvelteKit load |
| Data that outlives components | Store |
| Subtree-scoped shared state | Context |
| Need different values per subtree | Context |
| Non-component code access | Store |
Conclusion
Best practices aren’t constraints—they’re patterns that emerge from solving the same problems repeatedly. The conventions in this article represent accumulated wisdom from building and maintaining applications where context plays a central architectural role.
The investment in naming conventions pays dividends every time a developer (including future you) can guess a function name correctly without searching the codebase. The investment in type safety pays dividends every time TypeScript catches an error at compile time rather than in production. The investment in testability pays dividends when you can confidently refactor context logic knowing your tests will catch regressions.
Perhaps most importantly, knowing when not to use context prevents the common mistake of reaching for a powerful tool when a simpler one would suffice. Props are still the right choice for direct parent-child communication. Stores are still the right choice for truly global state. Load functions are still the right choice for server data. Context fills a specific niche—subtree-scoped shared state—and excels when used appropriately.
Apply these patterns consistently, and your context-based architecture will be a foundation you can build on confidently, not a source of confusion and bugs.
Key Takeaways
These practices transform context from a simple feature into a professional tool:
Start with the decision framework — Use props for direct relationships, context for subtree-scoped shared state, stores for global singletons, and load functions for server data.
Naming conventions matter — Use consistent patterns for keys (
user-auth), functions (createAuthContext,getAuthContext), files (auth.svelte.ts), and components (AuthProvider.svelte).Keep keys stable — Centralize keys and hide them behind accessor functions to enable changes without breaking consumers.
Type everything explicitly — Define interfaces, use
createContextor typed wrappers, and never rely on type inference for public APIs.Design minimal, action-oriented APIs — Expose getters (not raw state), provide actions alongside state, include loading/error states, and use result types for fallible operations.
Optimize thoughtfully — Throttle frequent updates, split large contexts, compute derived values in providers, and initialize expensive resources lazily.
Test at multiple levels — Extract logic to
.svelte.tsfiles for unit testing, create test wrapper components for integration tests, and mock context when testing consumers.Avoid common anti-patterns — No context for direct parent-child, no monolithic contexts, no provider soup, no context for static config or styling.
Apply these practices consistently, and your context-based architecture will be a pleasure to work with—maintainable, testable, and scalable.
What’s Next
Learn what can go wrong and how to fix it in Context Pitfalls and Debugging, covering common mistakes, debugging techniques, and traps that catch even experienced developers.
See Also
Official Documentation
- Svelte Context — Official guide
- Svelte Testing — Testing patterns
- .svelte.ts Files — Runes in TypeScript
Related Articles
- Providing Context — Context basics
- Reactive Context Fundamentals — Reactive patterns
- Authentication System — Auth context example
- Shopping Cart — Cart context example
- Context vs Other Patterns — Pattern comparison