The API You Build Today

You’ve made your context reactive. Consumers see updates when state changes. The plumbing works. But now comes a more subtle challenge: what should your context API actually look like?

Consider a shopping cart. You could expose the raw items array and let consumers push, splice, and mutate however they want. Or you could provide specific methods like add(), remove(), and updateQuantity(). Both approaches make the cart functional, but they lead to very different codebases six months from now.

The first approach is quick but fragile. Any component can corrupt the cart state. Business rules get scattered across the application. Debugging becomes archaeology. The second approach requires more upfront thought but creates a contract—a clear boundary between what the provider manages and what consumers can do.

This article explores that design space. You’ll learn when to expose read-only data versus read-write capabilities, why action methods beat raw state access, and how to build context APIs that remain stable as your application grows. By the end, you’ll have a framework for designing context that’s both powerful and maintainable.


Two Philosophies of Shared State

When you provide context, you’re implicitly choosing how much control to give consumers. This choice shapes everything that follows.

The Read-Only Approach

Read-only context exposes data without providing any mechanism to modify it. The provider maintains complete control over all state changes:

<!-- UserProvider.svelte -->
<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let user = $state(null)
	let loading = $state(true)
	let error = $state(null)

	// Only expose reading capabilities
	setContext('user', {
		get current() {
			return user
		},
		get isLoggedIn() {
			return user !== null
		},
		get displayName() {
			return user?.name ?? 'Guest'
		},
		get isLoading() {
			return loading
		},
		get error() {
			return error
		}
	})

	// Provider controls all updates internally
	async function loadUser() {
		loading = true
		error = null

		try {
			const response = await fetch('/api/user')
			if (!response.ok) throw new Error('Failed to load user')
			user = await response.json()
		} catch (e) {
			error = e.message
			user = null
		} finally {
			loading = false
		}
	}

	loadUser()
</script>

{@render children()}

Consumers can read any exposed property but have no way to modify the user data:

<!-- UserGreeting.svelte -->
<script>
	import { getContext } from 'svelte'

	const userCtx = getContext('user')
</script>

{#if userCtx.isLoading}
	<p>Loading...</p>
{:else if userCtx.error}
	<p class="error">Error: {userCtx.error}</p>
{:else}
	<p>Welcome back, {userCtx.displayName}!</p>
{/if}

The consumer displays user information but cannot change it. If you need to log the user out or update their profile, those operations live in the provider or in components that have direct access to the provider’s internal methods.

When Read-Only Makes Sense

Read-only context works well in several scenarios. When dealing with derived or computed data—values calculated from other sources that consumers shouldn’t directly manipulate. For configuration that remains constant during the application lifecycle, like feature flags loaded at startup. For external data fetched from APIs where the source of truth lives on a server, not in the browser. When preventing accidental changes is critical because modifications could break invariants or corrupt related state.

The read-only pattern enforces a clear data flow: information flows down from the provider, and if consumers need to trigger changes, they do so through events, callbacks, or other mechanisms that the provider explicitly supports.

The Read-Write Approach

Read-write context provides both reading capabilities and controlled ways to update state:

<!-- ThemeProvider.svelte -->
<script>
	import { setContext } from 'svelte'

	let { initialTheme = 'light', children } = $props()

	let theme = $state(initialTheme)

	setContext('theme', {
		// Reading
		get current() {
			return theme
		},
		get isDark() {
			return theme === 'dark'
		},
		get isLight() {
			return theme === 'light'
		},

		// Writing (controlled mutations)
		set(value) {
			if (value === 'light' || value === 'dark' || value === 'system') {
				theme = value
			}
		},
		toggle() {
			theme = theme === 'light' ? 'dark' : 'light'
		},
		reset() {
			theme = initialTheme
		}
	})

	// Sync to document for CSS
	$effect(() => {
		document.documentElement.setAttribute('data-theme', theme)
	})
</script>

{@render children()}

Now any consumer can read the theme and trigger updates:

<!-- ThemeControls.svelte -->
<script>
	import { getContext } from 'svelte'

	const theme = getContext('theme')
</script>

<div class="theme-controls">
	<button onclick={theme.toggle}>
		{theme.isDark ? '☀️ Switch to Light' : '🌙 Switch to Dark'}
	</button>

	<select value={theme.current} onchange={(e) => theme.set(e.target.value)}>
		<option value="light">Light</option>
		<option value="dark">Dark</option>
		<option value="system">System</option>
	</select>

	<button onclick={theme.reset}>Reset to Default</button>
</div>

The critical difference from exposing raw state is that consumers use the methods you provide. They call theme.set('dark'), not theme.current = 'dark'. This distinction matters enormously.


The Case for Action Methods

One of the most important context design principles is providing action methods instead of raw state access. This single decision determines whether your context scales gracefully or becomes a maintenance burden.

The Problem with Exposing Raw State

Consider what happens when you expose the underlying state directly:

<!-- CartProvider.svelte — PROBLEMATIC APPROACH -->
<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let items = $state([])

	// Exposing raw state directly
	setContext('cart', items)
</script>

{@render children()}

Consumers receive the array and can do anything with it:

<!-- SomeConsumer.svelte -->
<script>
	import { getContext } from 'svelte'

	const cart = getContext('cart')

	function addItem(product) {
		// Direct mutation — works, but...
		cart.push({ ...product, quantity: 1 })
	}

	function clearCart() {
		// Another direct mutation
		cart.length = 0
	}

	function doSomethingWeird() {
		// Also possible!
		cart[0] = { id: 'hacked', price: 0 }
		cart.splice(Math.floor(Math.random() * cart.length), 1)
	}
</script>

Every consumer can mutate the array however they want. There’s no validation, no business logic enforcement, no consistency guarantees. If your cart has rules—maximum quantities, price calculations, inventory checks—you have no central place to enforce them.

Debugging becomes painful because mutations can originate from anywhere. When the cart enters an invalid state, you have to search every file that accesses the context to find the culprit.

The Solution: Controlled Action Methods

Instead of exposing raw state, expose an object with getters for reading and methods for writing:

<!-- CartProvider.svelte — BETTER APPROACH -->
<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let items = $state([])

	setContext('cart', {
		// ─── Reading ───────────────────────────────────────
		get items() {
			return items
		},
		get count() {
			return items.reduce((sum, item) => sum + item.quantity, 0)
		},
		get subtotal() {
			return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
		},
		get isEmpty() {
			return items.length === 0
		},

		// ─── Actions ───────────────────────────────────────
		add(product) {
			if (!product?.id || typeof product.price !== 'number') {
				console.warn('Invalid product:', product)
				return false
			}

			const existing = items.find((item) => item.id === product.id)

			if (existing) {
				if (existing.quantity < 99) {
					existing.quantity++
					return true
				}
				return false // Max quantity reached
			}

			items.push({
				id: product.id,
				name: product.name,
				price: product.price,
				quantity: 1
			})
			return true
		},

		remove(productId) {
			const index = items.findIndex((item) => item.id === productId)
			if (index !== -1) {
				items.splice(index, 1)
				return true
			}
			return false
		},

		updateQuantity(productId, quantity) {
			if (typeof quantity !== 'number' || quantity < 0 || quantity > 99) {
				return false
			}

			const item = items.find((i) => i.id === productId)
			if (!item) return false

			if (quantity === 0) {
				return this.remove(productId)
			}

			item.quantity = quantity
			return true
		},

		clear() {
			items.length = 0
		}
	})
</script>

{@render children()}

Now consumers interact through the defined API:

<!-- AddToCartButton.svelte -->
<script>
	import { getContext } from 'svelte'

	let { product } = $props()

	const cart = getContext('cart')

	function handleClick() {
		const success = cart.add(product)
		if (!success) {
			alert('Could not add item to cart')
		}
	}
</script>

<button onclick={handleClick}>Add to Cart</button>
<!-- CartSummary.svelte -->
<script>
	import { getContext } from 'svelte'

	const cart = getContext('cart')
</script>

<div class="cart-summary">
	{#if cart.isEmpty}
		<p>Your cart is empty</p>
	{:else}
		<p>{cart.count} items · ${(cart.subtotal / 100).toFixed(2)}</p>

		{#each cart.items as item (item.id)}
			<div class="cart-item">
				<span>{item.name}</span>
				<input
					type="number"
					min="1"
					max="99"
					value={item.quantity}
					onchange={(e) => cart.updateQuantity(item.id, parseInt(e.target.value))}
				/>
				<button onclick={() => cart.remove(item.id)}>Remove</button>
			</div>
		{/each}

		<button onclick={() => cart.clear()}>Clear Cart</button>
	{/if}
</div>

Why Actions Win

The benefits compound as your application grows.

Validation happens in one place. The add method checks that products have valid IDs and prices. The updateQuantity method enforces the 0-99 range. Consumers don’t need to remember these rules—they’re baked into the API.

Business logic stays encapsulated. If adding an item should also check inventory, log analytics, or sync with a server, you add that logic once in the provider. Every consumer benefits automatically.

Debugging becomes tractable. When something goes wrong with the cart, you examine the action methods. You can add logging, breakpoints, or error handling in a single location.

Refactoring stays safe. If you need to change how items are stored internally—maybe switching from an array to a Map for faster lookups—consumers don’t need to change. They still call cart.add(product), unaware of the implementation details.

Intent is documented. Method names like add, remove, and clear describe what the caller wants to accomplish. Raw mutations like items.push() or items.splice(2, 1) describe implementation details that obscure intent.


Designing for Stability

Once you publish a context API, consumers depend on it. Every getter they read, every method they call, becomes part of your contract. Changing the API breaks their code.

Choose Names That Last

Property and method names should describe concepts, not implementations:

<script>
	// ❌ Too specific — what if you add 'system' or 'auto' themes?
	setContext('theme', {
		get isLight() {
			return theme === 'light'
		},
		get isDark() {
			return theme === 'dark'
		}
	})

	// ✅ More flexible — works with any theme value
	setContext('theme', {
		get current() {
			return theme
		},
		is(value) {
			return theme === value
		}
	})
</script>

The second version handles future theme values without API changes. Consumers write theme.is('dark') instead of theme.isDark, and when you add a 'system' theme, existing code keeps working.

Prefer Additive Changes

Adding new properties and methods is safe—existing code ignores what it doesn’t use. Removing or renaming breaks consumers immediately.

<script>
	// Version 1.0
	setContext('cart', {
		get items() {
			return items
		},
		add(product) {
			/* ... */
		},
		remove(id) {
			/* ... */
		}
	})

	// Version 1.1 — SAFE: only additions
	setContext('cart', {
		get items() {
			return items
		},
		get count() {
			return items.length
		}, // New
		get isEmpty() {
			return items.length === 0
		}, // New
		add(product) {
			/* ... */
		},
		addMultiple(products) {
			/* ... */
		}, // New
		remove(id) {
			/* ... */
		},
		clear() {
			/* ... */
		} // New
	})

	// Version 2.0 — BREAKING: renamed method
	setContext('cart', {
		get items() {
			return items
		},
		addItem(product) {
			/* ... */
		} // Was 'add' — breaks all callers!
	})
</script>

If you must make breaking changes, consider keeping the old API working alongside the new one, at least temporarily:

<script>
	setContext('cart', {
		// New preferred name
		addItem(product) {
			/* ... */
		},

		// Deprecated but still works
		add(product) {
			console.warn('cart.add() is deprecated, use cart.addItem()')
			return this.addItem(product)
		}
	})
</script>

Handle Edge Cases Explicitly

Every action method should consider what happens with invalid input:

<script>
	setContext('cart', {
		remove(productId) {
			// ❌ Dangerous: splice(-1, 1) removes the LAST item
			const index = items.findIndex((i) => i.id === productId)
			items.splice(index, 1)
		}
	})

	// ✅ Safe: validate before operating
	setContext('cart', {
		remove(productId) {
			const index = items.findIndex((i) => i.id === productId)
			if (index === -1) {
				return false // Item not found
			}
			items.splice(index, 1)
			return true
		}
	})
</script>

The second version handles the “item not found” case explicitly. Callers can check the return value if they care, or ignore it if they don’t. Either way, the cart state remains consistent.


The Provider-Consumer Contract

Think of context as a formal contract between two parties with distinct responsibilities.

┌─────────────────────────────────────────────────────────────┐
│  PROVIDER RESPONSIBILITIES                                  │
│  ─────────────────────────                                  │
│  • Initialize state with sensible defaults                  │
│  • Validate all inputs to action methods                    │
│  • Maintain data consistency and invariants                 │
│  • Handle side effects (persistence, API calls, logging)    │
│  • Define and document the public API                       │
│  • Ensure getters always return current, valid data         │
└─────────────────────────────────────────────────────────────┘

                              │ Context Object
                              │ (the contract)

┌─────────────────────────────────────────────────────────────┐
│  CONSUMER RESPONSIBILITIES                                  │
│  ─────────────────────────                                  │
│  • Use only the public API (getters and methods)            │
│  • Trust that getters return current, reactive data         │
│  • Call actions for all mutations                           │
│  • Handle potential failures (check return values)          │
│  • Avoid assumptions about internal implementation          │
└─────────────────────────────────────────────────────────────┘

This separation creates a clean boundary. The provider can change its internal implementation—switching data structures, adding caching, introducing optimizations—without affecting consumers. Consumers can evolve their UI and logic without worrying about corrupting shared state.


Complete Example: Multi-Step Form Context

Let’s apply these principles to a realistic scenario: a multi-step form with validation, navigation, and submission handling.

<!-- FormProvider.svelte -->
<script>
	import { setContext } from 'svelte'

	let { steps, initialData = {}, onSubmit, children } = $props()

	// ─── Internal State ────────────────────────────────────
	// Note: initialData is captured at mount time. This is intentional—
	// forms typically don't need to react to prop changes mid-session.
	// The reset() method uses this captured value to restore initial state.
	const capturedInitialData = { ...initialData }
	let formData = $state({ ...capturedInitialData })
	let currentStepIndex = $state(0)
	let fieldErrors = $state({})
	let formError = $state(null)
	let submitting = $state(false)
	let submitted = $state(false)

	// ─── Derived Values ────────────────────────────────────
	let currentStep = $derived(steps[currentStepIndex])
	let isFirstStep = $derived(currentStepIndex === 0)
	let isLastStep = $derived(currentStepIndex === steps.length - 1)
	let hasErrors = $derived(Object.keys(fieldErrors).length > 0)
	let progress = $derived(((currentStepIndex + 1) / steps.length) * 100)

	// ─── Helper Functions ──────────────────────────────────
	function validateCurrentStep() {
		fieldErrors = {}

		if (currentStep.validate) {
			const errors = currentStep.validate(formData)
			if (errors && Object.keys(errors).length > 0) {
				fieldErrors = errors
				return false
			}
		}
		return true
	}

	// ─── Context API ───────────────────────────────────────
	setContext('form', {
		// Reading: Form Data
		get data() {
			return formData
		},
		getField(name) {
			return formData[name] ?? ''
		},

		// Reading: Navigation State
		get currentStep() {
			return currentStep
		},
		get stepIndex() {
			return currentStepIndex
		},
		get stepCount() {
			return steps.length
		},
		get isFirstStep() {
			return isFirstStep
		},
		get isLastStep() {
			return isLastStep
		},
		get progress() {
			return progress
		},

		// Reading: Validation State
		get errors() {
			return fieldErrors
		},
		get formError() {
			return formError
		},
		get hasErrors() {
			return hasErrors
		},
		getError(name) {
			return fieldErrors[name] ?? null
		},

		// Reading: Submission State
		get isSubmitting() {
			return submitting
		},
		get isSubmitted() {
			return submitted
		},

		// Actions: Update Fields
		setField(name, value) {
			formData[name] = value
			// Clear field error when user edits
			if (fieldErrors[name]) {
				delete fieldErrors[name]
			}
			// Clear form-level error on any edit
			formError = null
		},

		setFields(updates) {
			Object.entries(updates).forEach(([name, value]) => {
				formData[name] = value
				if (fieldErrors[name]) {
					delete fieldErrors[name]
				}
			})
			formError = null
		},

		// Actions: Validation
		setFieldError(name, message) {
			if (message) {
				fieldErrors[name] = message
			} else {
				delete fieldErrors[name]
			}
		},

		setFormError(message) {
			formError = message
		},

		clearErrors() {
			fieldErrors = {}
			formError = null
		},

		validateStep() {
			return validateCurrentStep()
		},

		// Actions: Navigation
		nextStep() {
			if (isLastStep) return false
			if (!validateCurrentStep()) return false

			currentStepIndex++
			return true
		},

		prevStep() {
			if (isFirstStep) return false
			currentStepIndex--
			return true
		},

		goToStep(index) {
			if (index < 0 || index >= steps.length) return false
			if (index > currentStepIndex && !validateCurrentStep()) return false

			currentStepIndex = index
			return true
		},

		// Actions: Submission
		async submit() {
			if (submitting || submitted) return false
			if (!validateCurrentStep()) return false

			submitting = true
			formError = null

			try {
				await onSubmit(formData)
				submitted = true
				return true
			} catch (e) {
				formError = e.message || 'Submission failed'
				return false
			} finally {
				submitting = false
			}
		},

		// Actions: Reset
		reset() {
			formData = { ...capturedInitialData }
			currentStepIndex = 0
			fieldErrors = {}
			formError = null
			submitting = false
			submitted = false
		}
	})
</script>

{@render children()}

Now consumers have a clear, comprehensive API to work with:

<!-- FormProgress.svelte -->
<script>
	import { getContext } from 'svelte'

	const form = getContext('form')
</script>

<div class="progress-container">
	<div class="progress-bar">
		<div class="progress-fill" style="width: {form.progress}%"></div>
	</div>

	<p class="progress-text">
		Step {form.stepIndex + 1} of {form.stepCount}: {form.currentStep.title}
	</p>
</div>

<style>
	.progress-container {
		margin-bottom: 1.5rem;
	}

	.progress-bar {
		height: 4px;
		background: #e5e7eb;
		border-radius: 2px;
		overflow: hidden;
	}

	.progress-fill {
		height: 100%;
		background: #3b82f6;
		transition: width 0.3s ease;
	}

	.progress-text {
		margin-top: 0.5rem;
		font-size: 0.875rem;
		color: #6b7280;
	}
</style>
<!-- FormField.svelte -->
<script>
	import { getContext } from 'svelte'

	let { name, label, type = 'text', required = false } = $props()

	const form = getContext('form')

	let error = $derived(form.getError(name))
	let value = $derived(form.getField(name))
</script>

<div class="field" class:has-error={error}>
	<label for={name}>
		{label}
		{#if required}<span class="required">*</span>{/if}
	</label>

	<input
		id={name}
		{type}
		{value}
		oninput={(e) => form.setField(name, e.target.value)}
		aria-invalid={error ? 'true' : undefined}
		aria-describedby={error ? `${name}-error` : undefined}
	/>

	{#if error}
		<p id="{name}-error" class="error-message">{error}</p>
	{/if}
</div>

<style>
	.field {
		margin-bottom: 1rem;
	}

	label {
		display: block;
		margin-bottom: 0.25rem;
		font-weight: 500;
		color: #374151;
	}

	.required {
		color: #ef4444;
		margin-left: 0.25rem;
	}

	input {
		width: 100%;
		padding: 0.625rem 0.75rem;
		border: 1px solid #d1d5db;
		border-radius: 6px;
		font-size: 1rem;
		transition:
			border-color 0.15s,
			box-shadow 0.15s;
	}

	input:focus {
		outline: none;
		border-color: #3b82f6;
		box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
	}

	.has-error input {
		border-color: #ef4444;
	}

	.has-error input:focus {
		box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
	}

	.error-message {
		margin-top: 0.25rem;
		font-size: 0.875rem;
		color: #ef4444;
	}
</style>
<!-- FormNavigation.svelte -->
<script>
	import { getContext } from 'svelte'

	const form = getContext('form')
</script>

<div class="navigation">
	{#if !form.isFirstStep}
		<button type="button" onclick={() => form.prevStep()} class="btn-secondary"> ← Back </button>
	{/if}

	<div class="spacer"></div>

	{#if form.isLastStep}
		<button
			type="button"
			onclick={() => form.submit()}
			disabled={form.isSubmitting}
			class="btn-primary"
		>
			{form.isSubmitting ? 'Submitting...' : 'Submit'}
		</button>
	{:else}
		<button type="button" onclick={() => form.nextStep()} class="btn-primary"> Next → </button>
	{/if}
</div>

{#if form.formError}
	<div class="form-error" role="alert">
		<p>{form.formError}</p>
	</div>
{/if}

<style>
	.navigation {
		display: flex;
		gap: 1rem;
		margin-top: 1.5rem;
		padding-top: 1.5rem;
		border-top: 1px solid #e5e7eb;
	}

	.spacer {
		flex: 1;
	}

	button {
		padding: 0.625rem 1.25rem;
		border: none;
		border-radius: 6px;
		font-weight: 500;
		font-size: 0.9375rem;
		cursor: pointer;
		transition: background-color 0.15s;
	}

	button:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	.btn-primary {
		background: #3b82f6;
		color: white;
	}

	.btn-primary:hover:not(:disabled) {
		background: #2563eb;
	}

	.btn-secondary {
		background: #f3f4f6;
		color: #374151;
	}

	.btn-secondary:hover:not(:disabled) {
		background: #e5e7eb;
	}

	.form-error {
		margin-top: 1rem;
		padding: 0.75rem 1rem;
		background: #fef2f2;
		border: 1px solid #fecaca;
		border-radius: 6px;
	}

	.form-error p {
		margin: 0;
		color: #dc2626;
		font-size: 0.875rem;
	}
</style>
<!-- FormStepContent.svelte -->
<script>
	import { getContext } from 'svelte'
	import FormField from './FormField.svelte'

	const form = getContext('form')
</script>

{#if form.currentStep.id === 'personal'}
	<FormField name="name" label="Full Name" required />
	<FormField name="email" label="Email Address" type="email" required />
	<FormField name="phone" label="Phone Number" type="tel" />
{:else if form.currentStep.id === 'address'}
	<FormField name="street" label="Street Address" required />
	<FormField name="city" label="City" required />
	<FormField name="state" label="State / Province" />
	<FormField name="zip" label="ZIP / Postal Code" />
{:else if form.currentStep.id === 'review'}
	<div class="review-section">
		<h3>Review Your Information</h3>
		<dl class="review-list">
			<div class="review-item">
				<dt>Name</dt>
				<dd>{form.data.name || ''}</dd>
			</div>
			<div class="review-item">
				<dt>Email</dt>
				<dd>{form.data.email || ''}</dd>
			</div>
			<div class="review-item">
				<dt>Phone</dt>
				<dd>{form.data.phone || ''}</dd>
			</div>
			<div class="review-item">
				<dt>Address</dt>
				<dd>
					{#if form.data.street}
						{form.data.street}<br />
						{form.data.city}{form.data.state ? `, ${form.data.state}` : ''}
						{form.data.zip || ''}
					{:else}

					{/if}
				</dd>
			</div>
		</dl>
	</div>
{/if}

<style>
	.review-section h3 {
		margin: 0 0 1rem;
		font-size: 1.125rem;
		color: #1f2937;
	}

	.review-list {
		margin: 0;
	}

	.review-item {
		display: grid;
		grid-template-columns: 120px 1fr;
		gap: 0.5rem;
		padding: 0.75rem 0;
		border-bottom: 1px solid #e5e7eb;
	}

	.review-item:last-child {
		border-bottom: none;
	}

	dt {
		font-weight: 500;
		color: #6b7280;
	}

	dd {
		margin: 0;
		color: #1f2937;
	}
</style>

Putting it all together:

<!-- src/routes/register/+page.svelte -->
<script>
	import FormProvider from '$lib/components/FormProvider.svelte'
	import FormProgress from '$lib/components/FormProgress.svelte'
	import FormStepContent from '$lib/components/FormStepContent.svelte'
	import FormNavigation from '$lib/components/FormNavigation.svelte'

	const steps = [
		{
			id: 'personal',
			title: 'Personal Information',
			validate(data) {
				const errors = {}
				if (!data.name?.trim()) {
					errors.name = 'Name is required'
				}
				if (!data.email?.trim()) {
					errors.email = 'Email is required'
				} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
					errors.email = 'Please enter a valid email address'
				}
				return Object.keys(errors).length > 0 ? errors : null
			}
		},
		{
			id: 'address',
			title: 'Address',
			validate(data) {
				const errors = {}
				if (!data.street?.trim()) {
					errors.street = 'Street address is required'
				}
				if (!data.city?.trim()) {
					errors.city = 'City is required'
				}
				return Object.keys(errors).length > 0 ? errors : null
			}
		},
		{
			id: 'review',
			title: 'Review & Submit',
			validate: null
		}
	]

	async function handleSubmit(data) {
		// Simulate API call
		await new Promise((resolve) => setTimeout(resolve, 1500))
		console.log('Registration complete:', data)
	}
</script>

<div class="page">
	<h1>Create Account</h1>

	<FormProvider {steps} onSubmit={handleSubmit}>
		<div class="form-container">
			<FormProgress />
			<FormStepContent />
			<FormNavigation />
		</div>
	</FormProvider>
</div>

<style>
	.page {
		max-width: 500px;
		margin: 2rem auto;
		padding: 0 1rem;
	}

	h1 {
		margin: 0 0 1.5rem;
		font-size: 1.75rem;
		color: #1f2937;
		text-align: center;
	}

	.form-container {
		padding: 1.5rem;
		background: white;
		border-radius: 12px;
		box-shadow:
			0 1px 3px rgba(0, 0, 0, 0.1),
			0 1px 2px rgba(0, 0, 0, 0.06);
	}
</style>

This example demonstrates every principle we’ve discussed. The provider manages all state internally. Consumers use a clean API of getters and action methods. Validation happens centrally. Navigation guards against invalid progression. The API is additive-friendly and documents its intent through method names.


Common Mistakes and How to Avoid Them

1: Exposing Raw Setters Without Validation

<script>
	let count = $state(0)

	// ❌ No validation — consumers can set anything
	setContext('counter', {
		get value() {
			return count
		},
		set value(v) {
			count = v
		}
	})
</script>

Consumers might set negative numbers, strings, or undefined. Instead, provide explicit action methods:

<script>
	setContext('counter', {
		get value() {
			return count
		},
		increment() {
			count++
		},
		decrement() {
			if (count > 0) count--
		},
		set(v) {
			if (typeof v === 'number' && v >= 0 && Number.isFinite(v)) {
				count = Math.floor(v)
			}
		}
	})
</script>

2: Not Handling Edge Cases in Action Methods

<script>
	// ❌ Dangerous when item doesn't exist
	setContext('list', {
		remove(id) {
			const index = items.findIndex((i) => i.id === id)
			items.splice(index, 1) // splice(-1, 1) removes last item!
		}
	})
</script>

Always validate before operating:

<script>
	setContext('list', {
		remove(id) {
			const index = items.findIndex((i) => i.id === id)
			if (index !== -1) {
				items.splice(index, 1)
				return true
			}
			return false
		}
	})
</script>

3: Mixing Read-Only and Mutable Returns

<script>
	// ❌ Confusing: items getter returns mutable array
	setContext('cart', {
		get items() {
			return items
		} // Consumers can still mutate!
	})
</script>

If you want true read-only behavior, return a copy:

<script>
	setContext('cart', {
		get items() {
			return [...items]
		}, // New array each time
		get itemCount() {
			return items.length
		}
	})
</script>

Note that returning copies has performance implications for large arrays or frequent access. Choose based on your specific needs.


Performance and Scaling Considerations

Context API design impacts performance as your application scales. Understanding these trade-offs helps you make informed decisions.

Getter Evaluation Frequency

Getters in your context object execute every time they’re accessed. For simple property returns, this overhead is negligible. However, for expensive computations, prefer $derived values that cache results until dependencies change:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let items = $state([])

	// ❌ Recalculates on every access
	setContext('cart-inefficient', {
		get total() {
			return items.reduce((sum, item) => {
				return sum + calculateComplexTotal(item) // Expensive operation
			}, 0)
		}
	})

	// ✅ Caches result until items change
	let total = $derived(items.reduce((sum, item) => sum + calculateComplexTotal(item), 0))

	setContext('cart-efficient', {
		get total() {
			return total
		}
	})

	function calculateComplexTotal(item) {
		// Simulates expensive calculation
		return item.price * item.quantity * (1 - (item.discount || 0))
	}
</script>

{@render children()}

The first approach recalculates on every cart.total access. The second calculates once when items changes, then returns the cached value.

Context Object Size

Large context objects with many methods don’t inherently impact performance—JavaScript objects are cheap to create and access. However, consider these factors:

Deeply nested reactive state requires more fine-grained tracking by Svelte’s reactivity system. Keep state structures reasonably flat when possible. An array of objects with a few properties each is fine; deeply nested trees of objects may benefit from normalization.

If your context has dozens of action methods, consider whether it’s doing too much. A form context with 15 methods is reasonable. A “god context” with 50 methods managing unrelated concerns should probably be split into multiple focused contexts.

Action Method Side Effects

Action methods that trigger network requests, heavy computations, or localStorage writes should consider debouncing or batching. The context provider is an excellent place to centralize such optimizations since all mutations flow through it:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let preferences = $state({})
	let saveTimeout = null

	function debouncedSave() {
		clearTimeout(saveTimeout)
		saveTimeout = setTimeout(() => {
			localStorage.setItem('preferences', JSON.stringify(preferences))
		}, 500)
	}

	setContext('preferences', {
		get all() {
			return preferences
		},
		set(key, value) {
			preferences[key] = value
			debouncedSave() // Batches rapid changes
		}
	})
</script>

{@render children()}

Consumers call preferences.set() freely without worrying about performance—the provider handles the optimization transparently.


When NOT to Use This Pattern

Action-method-based context APIs excel in many scenarios, but they’re not always the right choice.

Simple Value Sharing

If you’re sharing a single, simple value that doesn’t require validation or complex business logic, the ceremony of action methods adds complexity without proportional benefit:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let locale = $state('en')

	// For a simple locale setting, this might be overkill:
	setContext('locale-verbose', {
		get current() {
			return locale
		},
		setEnglish() {
			locale = 'en'
		},
		setSpanish() {
			locale = 'es'
		},
		setFrench() {
			locale = 'fr'
		},
		set(value) {
			if (['en', 'es', 'fr'].includes(value)) {
				locale = value
			}
		}
	})

	// Sometimes simpler is better:
	setContext('locale-simple', {
		get value() {
			return locale
		},
		set(v) {
			locale = v
		}
	})
</script>

{@render children()}

The simpler version is easier to understand and sufficient for straightforward use cases. Reserve the full action-method pattern for state that genuinely benefits from encapsulation.

Prop Drilling Is Actually Fine

Context solves the prop drilling problem, but shallow component trees don’t need this solution. If you’re passing data through two or three levels, explicit props are often clearer and more traceable:

<!-- Passing through 2-3 levels? Props are fine. -->
<Parent>
	<Child {user}>
		<Grandchild {user} />
	</Child>
</Parent>

<!-- Passing through 5+ levels? Consider context. -->

Props make data flow explicit. You can trace where data comes from by reading the component hierarchy. Context hides this flow, which is valuable for deeply shared state but adds cognitive overhead for simple cases.

Component-Local State

State that only one component uses shouldn’t live in context. Context is for genuinely shared state—data that multiple, potentially distant components need to coordinate around.

A modal’s open/closed state, a form’s local validation errors, an animation’s progress value—these belong in component-local $state, not context. Putting everything in context creates unnecessary coupling and makes components harder to reuse.

Highly Dynamic Consumer Sets

If components consuming your context mount and unmount rapidly (like items in a virtualized list), ensure your context API doesn’t create closures or subscriptions that could cause memory issues. The getter-based pattern described in this article handles this well, but be cautious with patterns that register callbacks or listeners.


Key Takeaways

Context API design shapes how your application evolves. The decisions you make early—read-only versus read-write, raw state versus action methods—compound over time into either a maintainable architecture or a tangled web of implicit dependencies.

Choose read-only when appropriate. Configuration, derived data, and externally-sourced information often shouldn’t be modified by consumers. Read-only context makes data flow explicit and prevents accidental corruption.

Provide actions, not raw state. Action methods like add(), remove(), and update() encapsulate validation, business logic, and side effects. They document intent, simplify debugging, and allow internal refactoring without breaking consumers.

Design for stability from the start. Choose names that won’t need to change. Prefer additive changes over modifications. Handle edge cases explicitly in every action method.

Respect the provider-consumer contract. Providers initialize, validate, and manage state. Consumers use the public API. This separation creates a clean boundary that scales with your application.

The effort you invest in designing your context API pays dividends every time a new component needs to interact with shared state. A well-designed API guides consumers toward correct usage and away from subtle bugs. It makes your codebase more predictable, more testable, and more pleasant to work with.


What’s Next

With clean API design mastered, explore professional practices for context in Context Best Practices, covering naming conventions, file organization, testing strategies, and performance considerations.


See Also

Official Documentation