Two Ways to Share State
When you provide context, you’re making a choice about how consumers can interact with that state. There are two fundamental approaches:
- Read-only context: Consumers can read values but cannot change them
- Read-write context: Consumers can both read and update values
Understanding when to use each is crucial for building maintainable applications.
Read-Only Context
Read-only context exposes data without providing any way to modify it. The provider controls all updates:
<!-- UserProvider.svelte -->
<script>
import { setContext } from 'svelte'
let { children } = $props()
let user = $state(null)
// Only expose reading, no mutations
setContext('user', {
get current() {
return user
},
get isLoggedIn() {
return user !== null
},
get displayName() {
return user?.name ?? 'Guest'
}
})
// Provider controls updates internally
async function loadUser() {
const response = await fetch('/api/user')
user = await response.json()
}
loadUser()
</script>
{@render children()} <!-- Consumer.svelte -->
<script>
import { getContext } from 'svelte'
const userCtx = getContext('user')
// Can read: userCtx.current, userCtx.isLoggedIn
// Cannot modify: no setter or action provided
</script>
<p>Hello, {userCtx.displayName}!</p> When to Use Read-Only
- Derived/computed data: Values calculated from other sources
- Configuration: Settings that shouldn’t change at runtime
- External data: Information from APIs that components shouldn’t mutate
- Preventing accidental changes: When modifications could break invariants
Read-Write Context
Read-write context provides both reading capabilities and controlled ways to update:
<!-- ThemeProvider.svelte -->
<script>
import { setContext } from 'svelte'
let { children } = $props()
let theme = $state('light')
setContext('theme', {
// Reading
get current() {
return theme
},
get isDark() {
return theme === 'dark'
},
// Writing (controlled mutations)
set(value) {
if (value === 'light' || value === 'dark') {
theme = value
}
},
toggle() {
theme = theme === 'light' ? 'dark' : 'light'
}
})
</script>
{@render children()} Now consumers can update the theme:
<!-- ThemeToggle.svelte -->
<script>
import { getContext } from 'svelte'
const theme = getContext('theme')
</script>
<button onclick={theme.toggle}>
Switch to {theme.isDark ? 'Light' : 'Dark'}
</button>
<select onchange={(e) => theme.set(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select> Actions Over Raw State
One of the most important context design principles: provide actions instead of raw state access.
The Problem with Raw State
<!-- ❌ Exposing raw state -->
<script>
let items = $state([])
setContext('cart', items) // Direct access to the array
</script> Consumers can do anything:
<script>
const cart = getContext('cart')
// All of these work, for better or worse
cart.push(item) // Add item
cart.length = 0 // Clear cart
cart[0] = differentItem // Replace item
cart.splice(Math.random() * 10, 1) // Chaos!
</script> No validation, no encapsulation, no way to ensure consistency.
The Solution: Action Methods
<!-- ✅ Exposing controlled actions -->
<script>
let items = $state([])
setContext('cart', {
get items() {
return items
},
get count() {
return items.length
},
get total() {
return items.reduce((s, i) => s + i.price * i.qty, 0)
},
add(product) {
const existing = items.find((i) => i.id === product.id)
if (existing) {
existing.qty++
} else {
items.push({ ...product, qty: 1 })
}
},
remove(productId) {
const index = items.findIndex((i) => i.id === productId)
if (index !== -1) {
items.splice(index, 1)
}
},
updateQuantity(productId, qty) {
const item = items.find((i) => i.id === productId)
if (item && qty > 0 && qty <= 99) {
item.qty = qty
}
},
clear() {
items.length = 0
}
})
</script> Now consumers use the documented API:
<script>
const cart = getContext('cart')
// Can only do what the API allows
cart.add(product) // ✅ Controlled addition
cart.updateQuantity(id, 5) // ✅ With validation
cart.clear() // ✅ Explicit intent
// Can't do random mutations anymore
</script> Benefits of Actions
- Validation: Methods can validate inputs before modifying state
- Business logic: Complex operations are encapsulated in one place
- Debugging: Easier to log, trace, and debug discrete actions
- Refactoring: Internal implementation can change without affecting consumers
- Intent: Method names document what the caller is trying to do
API Stability
Once you publish a context API, consumers depend on it. Changing it breaks code. Design for stability from the start.
Stable Property Names
Choose names that won’t need to change:
<script>
// ❌ Too specific - what if you add system theme?
setContext('theme', {
get isLight() {
return theme === 'light'
}
})
// ✅ More flexible
setContext('theme', {
get current() {
return theme
},
is(value) {
return theme === value
}
})
</script> Additive Changes Only
Adding new properties is safe. Removing or renaming breaks consumers:
<script>
// Version 1
setContext('cart', {
get items() {
return items
},
add(product) {
/* ... */
}
})
// Version 2 - SAFE: only additions
setContext('cart', {
get items() {
return items
},
get count() {
return items.length
}, // New!
add(product) {
/* ... */
},
addMultiple(products) {
/* ... */
} // New!
})
// Version 2 - BREAKING: renamed method
setContext('cart', {
get items() {
return items
},
addItem(product) {
/* ... */
} // ❌ Was 'add', now breaks callers
})
</script> Document the Contract
For shared libraries or team codebases, document what your context provides:
/**
* Cart context provides shopping cart functionality.
*
* @example
* const cart = getContext('cart');
* cart.add({ id: '123', name: 'Widget', price: 999 });
*
* @property {CartItem[]} items - Current cart items (read-only)
* @property {number} count - Total item count
* @property {number} total - Total price in cents
* @method add(product) - Add a product to cart
* @method remove(id) - Remove a product by ID
* @method clear() - Empty the cart
*/ The Provider-Consumer Contract
Think of context as a contract between provider and consumer:
┌─────────────────────────────────────────────────────────────┐
│ PROVIDER RESPONSIBILITIES │
│ ───────────────────────── │
│ • Initialize state │
│ • Validate all mutations │
│ • Maintain data consistency │
│ • Handle side effects (persistence, API calls) │
│ • Define the public API │
└─────────────────────────────────────────────────────────────┘
│
│ Context Object
│ (the contract)
▼
┌─────────────────────────────────────────────────────────────┐
│ CONSUMER RESPONSIBILITIES │
│ ───────────────────────── │
│ • Use only the public API │
│ • Trust that getters return current data │
│ • Call actions for mutations │
│ • Handle missing context gracefully │
└─────────────────────────────────────────────────────────────┘ Practical Example: Form State Context
Here’s a complete example showing read-write context for a multi-step form:
<!-- FormProvider.svelte -->
<script>
import { setContext } from 'svelte'
let { initialData = {}, onSubmit, children } = $props()
let formData = $state({ ...initialData })
let currentStep = $state(0)
let errors = $state({})
let isSubmitting = $state(false)
const steps = ['Personal', 'Address', 'Payment', 'Review']
setContext('form', {
// ─── Reading ───────────────────────────────────────
get data() {
return formData
},
get step() {
return currentStep
},
get stepName() {
return steps[currentStep]
},
get errors() {
return errors
},
get isSubmitting() {
return isSubmitting
},
get canGoBack() {
return currentStep > 0
},
get canGoForward() {
return currentStep < steps.length - 1
},
get isLastStep() {
return currentStep === steps.length - 1
},
// ─── Updating Data ─────────────────────────────────
updateField(name, value) {
formData[name] = value
// Clear error when field is edited
if (errors[name]) {
delete errors[name]
}
},
setError(name, message) {
errors[name] = message
},
clearErrors() {
errors = {}
},
// ─── Navigation ────────────────────────────────────
nextStep() {
if (currentStep < steps.length - 1) {
currentStep++
}
},
prevStep() {
if (currentStep > 0) {
currentStep--
}
},
goToStep(index) {
if (index >= 0 && index < steps.length) {
currentStep = index
}
},
// ─── Submission ────────────────────────────────────
async submit() {
if (isSubmitting) return
isSubmitting = true
errors = {}
try {
await onSubmit(formData)
} catch (e) {
errors.form = e.message
} finally {
isSubmitting = false
}
},
reset() {
formData = { ...initialData }
currentStep = 0
errors = {}
isSubmitting = false
}
})
</script>
{@render children()} <!-- FormStep.svelte (Consumer) -->
<script>
import { getContext } from 'svelte'
const form = getContext('form')
</script>
<div class="step">
<h2>Step {form.step + 1}: {form.stepName}</h2>
<slot />
{#if form.errors.form}
<p class="error">{form.errors.form}</p>
{/if}
<div class="navigation">
<button onclick={form.prevStep} disabled={!form.canGoBack}> Back </button>
{#if form.isLastStep}
<button onclick={form.submit} disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
{:else}
<button onclick={form.nextStep}> Next </button>
{/if}
</div>
</div> <!-- PersonalInfoStep.svelte (Consumer) -->
<script>
import { getContext } from 'svelte'
const form = getContext('form')
</script>
<div>
<label>
Name
<input
type="text"
value={form.data.name ?? ''}
oninput={(e) => form.updateField('name', e.target.value)}
/>
{#if form.errors.name}
<span class="error">{form.errors.name}</span>
{/if}
</label>
<label>
Email
<input
type="email"
value={form.data.email ?? ''}
oninput={(e) => form.updateField('email', e.target.value)}
/>
{#if form.errors.email}
<span class="error">{form.errors.email}</span>
{/if}
</label>
</div> Common Beginner Mistakes
1: Exposing Setter for Raw State
<script>
let count = $state(0)
setContext('counter', {
get value() {
return count
},
set value(v) {
count = v
} // ❌ No validation!
})
</script> Problem: Consumers can set any value—negative numbers, strings, undefined.
Fix: Use explicit action methods with validation:
<script>
setContext('counter', {
get value() {
return count
},
increment() {
count++
},
decrement() {
if (count > 0) count--
},
set(v) {
if (typeof v === 'number' && v >= 0) count = v
}
})
</script> 2: Allowing Direct Array/Object Mutations
<script>
setContext('cart', {
remove(id) {
const index = items.findIndex((i) => i.id === id)
items.splice(index, 1) // ❌ What if id doesn't exist? index = -1
}
})
</script> Problem: splice(-1, 1) removes the last item—probably not what you want.
Fix: Validate before operating:
<script>
setContext('cart', {
remove(id) {
const index = items.findIndex((i) => i.id === id)
if (index !== -1) {
items.splice(index, 1)
}
}
})
</script> Key Takeaways
Read-only vs read-write: Choose based on whether consumers should modify state. Read-only is safer but less flexible.
Actions over raw state: Provide methods that encapsulate operations. This enables validation, business logic, and stable APIs.
Design for stability: Think about the API contract. Prefer additive changes. Document expectations.
The provider owns the state: It initializes, validates, and manages. Consumers use the API; they don’t reach into internals.
Name actions by intent:
add(),remove(),clear()describe what the caller wants, not how it’s implemented.Handle edge cases: Every action should consider invalid inputs, missing data, and concurrent operations.
What’s Next
Ready to organize complex context logic? The next article, Class based Context, shows how to use TypeScript classes with Svelte 5 runes for encapsulated, type-safe context. You’ll learn to group state, actions, and computed values in one place, enforce private fields, and manage lifecycle effects—all with clean, maintainable code.
See Also
Related Articles
- Making Context Reactive — Foundation for reactive context patterns
- Reactive Context Patterns — Getters,
$derived, and$effectpatterns - Context Best Practices — Organization and testing strategies
- Context as a Feature Boundary — Encapsulate features behind clean APIs
- Class-Based Context — Organize complex context with TypeScript classes
Official Documentation
- Svelte Context — Official context documentation
- $state — Reactive state rune