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 TypeLocationReasoning
Theme, locale, feature flagsCentralizedUsed everywhere across the application
AuthEitherDepends on how auth-specific your components are
Shopping cartFeatureCart logic is feature-specific
Checkout wizardFeatureOnly used in checkout flow
Modal/toast managersCentralizedUsed 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 State

The $effect rune generally shouldn’t assign state—that’s what $derived is 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 $derived value
  • The effect properly cleans up its event listener

When you see $effect assigning state, ask: “Is this responding to something external?” If yes, it’s appropriate. If the value could be computed from other reactive state, use $derived instead.

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

NeedSolution
Pass data one level downProps
Pass data through 3+ levelsContext
Component library internal stateContext
Application-wide singletonStore
Route-specific server dataSvelteKit load
Data that outlives componentsStore
Subtree-scoped shared stateContext
Need different values per subtreeContext
Non-component code accessStore

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:

  1. 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.

  2. Naming conventions matter — Use consistent patterns for keys (user-auth), functions (createAuthContext, getAuthContext), files (auth.svelte.ts), and components (AuthProvider.svelte).

  3. Keep keys stable — Centralize keys and hide them behind accessor functions to enable changes without breaking consumers.

  4. Type everything explicitly — Define interfaces, use createContext or typed wrappers, and never rely on type inference for public APIs.

  5. 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.

  6. Optimize thoughtfully — Throttle frequent updates, split large contexts, compute derived values in providers, and initialize expensive resources lazily.

  7. Test at multiple levels — Extract logic to .svelte.ts files for unit testing, create test wrapper components for integration tests, and mock context when testing consumers.

  8. 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