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:

  1. Read-only context: Consumers can read values but cannot change them
  2. 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

  1. Validation: Methods can validate inputs before modifying state
  2. Business logic: Complex operations are encapsulated in one place
  3. Debugging: Easier to log, trace, and debug discrete actions
  4. Refactoring: Internal implementation can change without affecting consumers
  5. 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

  1. Read-only vs read-write: Choose based on whether consumers should modify state. Read-only is safer but less flexible.

  2. Actions over raw state: Provide methods that encapsulate operations. This enables validation, business logic, and stable APIs.

  3. Design for stability: Think about the API contract. Prefer additive changes. Document expectations.

  4. The provider owns the state: It initializes, validates, and manages. Consumers use the API; they don’t reach into internals.

  5. Name actions by intent: add(), remove(), clear() describe what the caller wants, not how it’s implemented.

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

Official Documentation