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
- Svelte Context — Official context guide
- $state — Reactive state rune
Related Articles
- Making Context Reactive — Fundamentals of reactive context
- Reactive Context Patterns — Getters, $derived, and $effect patterns
- Class-Based Context — Using classes for context
- Context Best Practices — Professional patterns