Building on the Fundamentals

In the previous article, you learned the fundamentals: why plain context isn’t reactive, how $state with objects fixes it, and the critical “mutate, don’t reassign” rule. You can now create context that updates everywhere.

But knowing the mechanics is just the beginning. Professional context APIs need more: they need to be encapsulated (internal state protected from misuse), computed (derived values that stay in sync automatically), and effectful (persisting to storage, syncing with external systems).

This article teaches the patterns that transform basic reactive context into production-quality APIs:

  • Getters for clean APIs — How to expose reactive values while hiding implementation details
  • $derived for computed values — Automatic calculations that update when dependencies change
  • $effect for side effects — Persistence, synchronization, and DOM updates
  • Context in .svelte.ts files — Reusable, type-safe context modules

By the end, you’ll be able to build context systems that are a pleasure to use and maintain.


Getters for Clean APIs

When you expose a $state object directly via context, consumers can read and write anything. That’s fine for simple cases, but problematic as complexity grows:

<!-- Provider -->
<script>
	let state = $state({ count: 0, step: 1 })
	setContext('counter', state)
</script>

<!-- Any consumer can do whatever they want -->
<script>
	const counter = getContext('counter')

	// Direct mutation — works but bypasses any validation
	counter.count = 999
	counter.step = -1

	// Consumers could even add random properties
	counter.hackedValue = 'oops'
</script>

This isn’t necessarily wrong, but it makes your code harder to reason about. Who’s changing what? Where do bugs come from?

The Getter Pattern

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

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

	let { initial = 0, step = 1, min = -Infinity, max = Infinity, children } = $props()

	// Internal state — not directly exposed
	let count = $state(initial)

	// Public API with getters and methods
	setContext('counter', {
		// Getters: reading goes through these
		get value() {
			return count
		},

		get canIncrement() {
			return count + step <= max
		},

		get canDecrement() {
			return count - step >= min
		},

		// Methods: writing goes through these
		increment() {
			if (count + step <= max) {
				count += step
			}
		},

		decrement() {
			if (count - step >= min) {
				count -= step
			}
		},

		set(newValue) {
			// Validation in one place
			count = Math.max(min, Math.min(max, newValue))
		},

		reset() {
			count = initial
		}
	})
</script>

{@render children()}

Consumers get a clean, constrained API:

<script>
	import { getContext } from 'svelte'

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

<div class="counter">
	<button onclick={counter.decrement} disabled={!counter.canDecrement}></button>

	<span>{counter.value}</span>

	<button onclick={counter.increment} disabled={!counter.canIncrement}> + </button>

	<button onclick={counter.reset}>Reset</button>
</div>

Why Getters Are Essential

Without getters, you might accidentally capture stale values:

let count = $state(0)

// ❌ Without getters — captures value at creation time
setContext('counter', {
	value: count, // This is 0, forever
	canIncrement: count < max, // This is true, forever
	increment() {
		count++
	}
})

// ✅ With getters — evaluates fresh each time
setContext('counter', {
	get value() {
		return count
	}, // Always current
	get canIncrement() {
		return count < max
	}, // Always current
	increment() {
		count++
	}
})

The getter function runs every time the property is accessed. Each render, each event handler, each $derived that reads counter.value gets the current value of count.

Getters Enable Encapsulation

With getters, you can change internal implementation without breaking consumers:

// Version 1: Simple counter
let count = $state(0)

setContext('counter', {
	get value() {
		return count
	},
	increment() {
		count++
	}
})

// Version 2: Now we want to track history
// Changed implementation, same API
let history = $state([0])

setContext('counter', {
	get value() {
		return history[history.length - 1]
	}, // Same API!
	increment() {
		history.push(history[history.length - 1] + 1)
	},
	undo() {
		// New feature
		if (history.length > 1) history.pop()
	}
})

Consumers using counter.value continue working without modification. This is the power of encapsulation.

Providing Both State and Actions

A well-designed context API typically has three parts:

setContext('feature', {
	// 1. State getters — what you can read
	get data() {
		return data
	},
	get isLoading() {
		return isLoading
	},
	get error() {
		return error
	},

	// 2. Derived getters — computed values
	get isEmpty() {
		return data.length === 0
	},
	get summary() {
		return computeSummary(data)
	},

	// 3. Action methods — what you can do
	async fetch() {
		/* ... */
	},
	add(item) {
		/* ... */
	},
	remove(id) {
		/* ... */
	},
	reset() {
		/* ... */
	}
})

This structure makes context self-documenting. Consumers immediately understand what’s available and how to use it.


Computed Values with $derived

When your context needs computed values that depend on state, use $derived. Derived values automatically recalculate when their dependencies change, keeping everything in sync without manual effort.

Basic $derived in Context

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

	let { children } = $props()

	// Core state
	let items = $state([])
	let discountPercent = $state(0)

	// Derived values — computed automatically
	let itemCount = $derived(items.length)

	let totalQuantity = $derived(items.reduce((sum, item) => sum + item.quantity, 0))

	let subtotal = $derived(items.reduce((sum, item) => sum + item.price * item.quantity, 0))

	let discountAmount = $derived(subtotal * (discountPercent / 100))

	let total = $derived(subtotal - discountAmount)

	let isEmpty = $derived(items.length === 0)

	// Context API
	setContext('cart', {
		// State
		get items() {
			return items
		},

		// Derived values
		get itemCount() {
			return itemCount
		},
		get totalQuantity() {
			return totalQuantity
		},
		get subtotal() {
			return subtotal
		},
		get discountAmount() {
			return discountAmount
		},
		get total() {
			return total
		},
		get isEmpty() {
			return isEmpty
		},

		// Methods
		addItem(product, quantity = 1) {
			const existing = items.find((i) => i.id === product.id)
			if (existing) {
				existing.quantity += quantity
			} else {
				items.push({
					id: product.id,
					name: product.name,
					price: product.price,
					quantity
				})
			}
		},

		removeItem(id) {
			const index = items.findIndex((i) => i.id === id)
			if (index !== -1) {
				items.splice(index, 1)
			}
		},

		applyDiscount(percent) {
			discountPercent = Math.max(0, Math.min(100, percent))
		},

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

{@render children()}

When any item is added, removed, or changed:

  1. items array is mutated
  2. All $derived values recalculate automatically
  3. Any consumer reading those values re-renders

Consumers Get Automatic Updates

<!-- 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.itemCount}
			{cart.itemCount === 1 ? 'item' : 'items'}
			({cart.totalQuantity} total)
		</p>

		<div class="totals">
			<div class="line">
				<span>Subtotal</span>
				<span>${(cart.subtotal / 100).toFixed(2)}</span>
			</div>
			{#if cart.discountAmount > 0}
				<div class="line discount">
					<span>Discount</span>
					<span>-${(cart.discountAmount / 100).toFixed(2)}</span>
				</div>
			{/if}
			<div class="line total">
				<span>Total</span>
				<span>${(cart.total / 100).toFixed(2)}</span>
			</div>
		</div>
	{/if}
</div>

The cart summary updates immediately when items change. You don’t have to manually recalculate totals or worry about stale data.

Chained Derivations

Derived values can depend on other derived values. Svelte handles the dependency graph automatically:

// Base state
let items = $state([])
let taxRate = $state(0.08)
let freeShippingThreshold = 5000 // $50

// First level: derived from state
let subtotal = $derived(items.reduce((sum, item) => sum + item.price * item.quantity, 0))

// Second level: derived from derived
let qualifiesForFreeShipping = $derived(subtotal >= freeShippingThreshold)
let shipping = $derived(qualifiesForFreeShipping ? 0 : 599)

// Third level: derived from multiple derived
let tax = $derived(Math.round(subtotal * taxRate))
let total = $derived(subtotal + shipping + tax)

When items changes, everything recalculates in the correct order: subtotal first, then qualifiesForFreeShipping and shipping, then tax and total.

Computing Once vs. Every Access

There’s a subtle difference between getters that compute and $derived:

// Getter: computes on every access
get expensive() {
	return items.map(i => heavyCalculation(i))
}

// $derived: computes once when dependencies change, then caches
let expensive = $derived(items.map(i => heavyCalculation(i)))
get expensive() { return expensive }

For cheap operations, the difference doesn’t matter. For expensive computations, use $derived to avoid redundant work.


Side Effects with $effect

Use $effect inside providers to react to context state changes. This is where you handle persistence, synchronization with external systems, analytics, and DOM updates.

Persisting to localStorage

A common pattern is saving user preferences:

<!-- ThemeProvider.svelte -->
<script>
	import { setContext } from 'svelte'
	import { browser } from '$app/environment'

	let { children } = $props()

	// Initialize from localStorage if available
	let initialTheme = 'light'
	if (browser) {
		const stored = localStorage.getItem('theme')
		if (stored === 'light' || stored === 'dark') {
			initialTheme = stored
		}
	}

	let theme = $state(initialTheme)

	// Persist changes to localStorage
	$effect(() => {
		if (browser) {
			localStorage.setItem('theme', theme)
		}
	})

	// Apply to document
	$effect(() => {
		if (browser) {
			document.documentElement.setAttribute('data-theme', theme)
		}
	})

	setContext('theme', {
		get current() {
			return theme
		},

		setTheme(value) {
			if (value === 'light' || value === 'dark') {
				theme = value
			}
		},

		toggle() {
			theme = theme === 'light' ? 'dark' : 'light'
		}
	})
</script>

{@render children()}

The $effect runs whenever theme changes, automatically keeping localStorage and the DOM in sync.

Syncing with External Systems

Here’s a pattern for keeping context synchronized with an API:

<!-- NotificationProvider.svelte -->
<script>
	import { setContext } from 'svelte'
	import { browser } from '$app/environment'

	let { children } = $props()

	let notifications = $state([])
	let unreadCount = $derived(notifications.filter((n) => !n.read).length)

	// Poll for new notifications
	$effect(() => {
		if (!browser) return

		async function checkForNew() {
			try {
				const response = await fetch('/api/notifications')
				const data = await response.json()

				// Merge new notifications (mutate, don't replace)
				for (const notification of data) {
					if (!notifications.find((n) => n.id === notification.id)) {
						notifications.push(notification)
					}
				}
			} catch (error) {
				console.error('Failed to fetch notifications:', error)
			}
		}

		// Check immediately and then every 30 seconds
		checkForNew()
		const interval = setInterval(checkForNew, 30000)

		// Cleanup on unmount
		return () => clearInterval(interval)
	})

	setContext('notifications', {
		get all() {
			return notifications
		},
		get unreadCount() {
			return unreadCount
		},

		markAsRead(id) {
			const notification = notifications.find((n) => n.id === id)
			if (notification) {
				notification.read = true
			}
		},

		markAllAsRead() {
			for (const notification of notifications) {
				notification.read = true
			}
		},

		dismiss(id) {
			const index = notifications.findIndex((n) => n.id === id)
			if (index !== -1) {
				notifications.splice(index, 1)
			}
		}
	})
</script>

{@render children()}

Effect Cleanup

When effects set up subscriptions, timers, or event listeners, return a cleanup function:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let networkStatus = $state({ online: navigator.onLine })

	$effect(() => {
		function handleOnline() {
			networkStatus.online = true
		}

		function handleOffline() {
			networkStatus.online = false
		}

		window.addEventListener('online', handleOnline)
		window.addEventListener('offline', handleOffline)

		// Cleanup: runs before effect re-runs or component unmounts
		return () => {
			window.removeEventListener('online', handleOnline)
			window.removeEventListener('offline', handleOffline)
		}
	})

	setContext('network', {
		get isOnline() {
			return networkStatus.online
		},
		get isOffline() {
			return !networkStatus.online
		}
	})
</script>

{@render children()}

The cleanup function prevents memory leaks and stale event handlers.

Multiple Effects for Separation of Concerns

Don’t put everything in one effect. Separate concerns for clarity:

<script>
	import { setContext } from 'svelte'
	import { browser } from '$app/environment'

	let { children } = $props()

	let user = $state(null)
	let preferences = $state({ theme: 'light', fontSize: 16 })

	// Effect 1: Persist user to sessionStorage
	$effect(() => {
		if (!browser) return

		if (user) {
			sessionStorage.setItem('user', JSON.stringify(user))
		} else {
			sessionStorage.removeItem('user')
		}
	})

	// Effect 2: Persist preferences to localStorage
	$effect(() => {
		if (!browser) return
		localStorage.setItem('preferences', JSON.stringify(preferences))
	})

	// Effect 3: Apply theme to document
	$effect(() => {
		if (!browser) return
		document.documentElement.setAttribute('data-theme', preferences.theme)
	})

	// Effect 4: Apply font size to document
	$effect(() => {
		if (!browser) return
		document.documentElement.style.setProperty('--base-font-size', `${preferences.fontSize}px`)
	})

	// Effect 5: Analytics tracking
	$effect(() => {
		if (!browser || !user) return

		analytics.identify(user.id, {
			email: user.email,
			name: user.name
		})
	})

	setContext('app', {
		get user() {
			return user
		},
		get preferences() {
			return preferences
		}
		// ... methods
	})
</script>

{@render children()}

Each effect has a single responsibility, making the code easier to understand and maintain.


Context in .svelte.ts Files

For reusable, type-safe context, create modules in .svelte.ts files. This separates context logic from components and enables better organization.

A Complete Counter Module

// lib/context/counter.svelte.ts
import { setContext, getContext, hasContext } from 'svelte'

// Use Symbol for collision-proof keys
const COUNTER_KEY = Symbol('counter')

// Type definitions
export interface CounterContext {
	readonly value: number
	readonly isAtMin: boolean
	readonly isAtMax: boolean
	increment(): void
	decrement(): void
	set(value: number): void
	reset(): void
}

export interface CounterOptions {
	initial?: number
	min?: number
	max?: number
	step?: number
}

// Creation function
export function createCounterContext(options: CounterOptions = {}): CounterContext {
	const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options

	// Internal state
	let count = $state(initial)

	// Derived values
	let isAtMin = $derived(count <= min)
	let isAtMax = $derived(count >= max)

	// Context API
	const context: CounterContext = {
		get value() {
			return count
		},
		get isAtMin() {
			return isAtMin
		},
		get isAtMax() {
			return isAtMax
		},

		increment() {
			if (count + step <= max) {
				count += step
			}
		},

		decrement() {
			if (count - step >= min) {
				count -= step
			}
		},

		set(value: number) {
			count = Math.max(min, Math.min(max, value))
		},

		reset() {
			count = initial
		}
	}

	return setContext(COUNTER_KEY, context)
}

// Getter with error handling
export function getCounterContext(): CounterContext {
	if (!hasContext(COUNTER_KEY)) {
		throw new Error(
			'Counter context not found. ' +
				'Wrap your component tree with CounterProvider ' +
				'or call createCounterContext().'
		)
	}
	return getContext(COUNTER_KEY)
}

// Optional getter for graceful handling
export function getCounterContextOrNull(): CounterContext | null {
	return hasContext(COUNTER_KEY) ? getContext(COUNTER_KEY) : null
}

// Check helper
export function hasCounterContext(): boolean {
	return hasContext(COUNTER_KEY)
}

Using the Module

Create a provider component:

<!-- CounterProvider.svelte -->
<script lang="ts">
	import { createCounterContext, type CounterOptions } from '$lib/context/counter.svelte'

	interface Props extends CounterOptions {
		children: import('svelte').Snippet
	}

	let { initial = 0, min, max, step, children }: Props = $props()

	createCounterContext({ initial, min, max, step })
</script>

{@render children()}

Use in consumers:

<!-- CounterDisplay.svelte -->
<script lang="ts">
	import { getCounterContext } from '$lib/context/counter.svelte'

	const counter = getCounterContext()
</script>

<div class="counter">
	<button onclick={counter.decrement} disabled={counter.isAtMin}></button>

	<span>{counter.value}</span>

	<button onclick={counter.increment} disabled={counter.isAtMax}> + </button>
</div>

TypeScript provides full autocomplete and type checking:

counter.value // ✅ number
counter.isAtMin // ✅ boolean
counter.increment() // ✅ void
counter.foo // ❌ Property 'foo' does not exist

A More Complex Example: Cart Context

// lib/context/cart.svelte.ts
import { setContext, getContext, hasContext } from 'svelte'
import { browser } from '$app/environment'

const CART_KEY = Symbol('cart')
const STORAGE_KEY = 'shopping_cart'

export interface CartItem {
	id: string
	name: string
	price: number
	quantity: number
}

export interface CartContext {
	readonly items: CartItem[]
	readonly itemCount: number
	readonly subtotal: number
	readonly isEmpty: boolean

	addItem(item: Omit<CartItem, 'quantity'>, quantity?: number): void
	removeItem(id: string): void
	updateQuantity(id: string, quantity: number): void
	clear(): void
}

export function createCartContext(): CartContext {
	// Load from storage
	function loadItems(): CartItem[] {
		if (!browser) return []
		try {
			const stored = localStorage.getItem(STORAGE_KEY)
			return stored ? JSON.parse(stored) : []
		} catch {
			return []
		}
	}

	// State
	let items = $state<CartItem[]>(loadItems())

	// Derived
	let itemCount = $derived(items.length)
	let subtotal = $derived(items.reduce((sum, item) => sum + item.price * item.quantity, 0))
	let isEmpty = $derived(items.length === 0)

	// Persistence effect
	$effect(() => {
		if (browser) {
			localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
		}
	})

	// API
	const context: CartContext = {
		get items() {
			return items
		},
		get itemCount() {
			return itemCount
		},
		get subtotal() {
			return subtotal
		},
		get isEmpty() {
			return isEmpty
		},

		addItem(item, quantity = 1) {
			const existing = items.find((i) => i.id === item.id)
			if (existing) {
				existing.quantity += quantity
			} else {
				items.push({ ...item, quantity })
			}
		},

		removeItem(id) {
			const index = items.findIndex((i) => i.id === id)
			if (index !== -1) {
				items.splice(index, 1)
			}
		},

		updateQuantity(id, quantity) {
			const item = items.find((i) => i.id === id)
			if (item) {
				if (quantity <= 0) {
					this.removeItem(id)
				} else {
					item.quantity = quantity
				}
			}
		},

		clear() {
			items.length = 0
		}
	}

	return setContext(CART_KEY, context)
}

export function getCartContext(): CartContext {
	if (!hasContext(CART_KEY)) {
		throw new Error('Cart context not found. Wrap with CartProvider.')
	}
	return getContext(CART_KEY)
}

Organizing Context Modules

A typical structure for a medium-sized app:

src/lib/context/
├── cart.svelte.ts        # Shopping cart
├── auth.svelte.ts        # Authentication
├── theme.svelte.ts       # Theme preferences
├── notifications.svelte.ts  # Toast/notification system
├── types.ts              # Shared types
└── index.ts              # Public exports

The index.ts re-exports everything for clean imports:

// lib/context/index.ts
export * from './cart.svelte'
export * from './auth.svelte'
export * from './theme.svelte'
export * from './notifications.svelte'

Consumers import what they need:

<script lang="ts">
	import { getCartContext, getAuthContext, getThemeContext } from '$lib/context'

	const cart = getCartContext()
	const auth = getAuthContext()
	const theme = getThemeContext()
</script>

Putting It All Together

Let’s see these patterns combined in a realistic notification system:

// lib/context/notifications.svelte.ts
import { setContext, getContext, hasContext } from 'svelte'

const NOTIFICATIONS_KEY = Symbol('notifications')

export type NotificationType = 'info' | 'success' | 'warning' | 'error'

export interface Notification {
	id: string
	message: string
	type: NotificationType
	dismissible: boolean
	createdAt: number
}

export interface NotificationContext {
	readonly all: Notification[]
	readonly count: number
	readonly hasNotifications: boolean

	// Convenience methods
	info(message: string): string
	success(message: string): string
	warning(message: string): string
	error(message: string): string

	// Core methods
	add(message: string, options?: NotificationOptions): string
	dismiss(id: string): void
	dismissAll(): void
}

export interface NotificationOptions {
	type?: NotificationType
	duration?: number
	dismissible?: boolean
}

export function createNotificationContext(): NotificationContext {
	let notifications = $state<Notification[]>([])

	// Derived
	let count = $derived(notifications.length)
	let hasNotifications = $derived(count > 0)

	function generateId(): string {
		return `notification-${Date.now()}-${Math.random().toString(36).slice(2)}`
	}

	function addNotification(message: string, options: NotificationOptions = {}): string {
		const { type = 'info', duration = type === 'error' ? 0 : 5000, dismissible = true } = options

		const id = generateId()

		notifications.push({
			id,
			message,
			type,
			dismissible,
			createdAt: Date.now()
		})

		// Auto-dismiss if duration > 0
		if (duration > 0) {
			setTimeout(() => dismiss(id), duration)
		}

		return id
	}

	function dismiss(id: string): void {
		const index = notifications.findIndex((n) => n.id === id)
		if (index !== -1) {
			notifications.splice(index, 1)
		}
	}

	function dismissAll(): void {
		notifications.length = 0
	}

	const context: NotificationContext = {
		get all() {
			return notifications
		},
		get count() {
			return count
		},
		get hasNotifications() {
			return hasNotifications
		},

		info: (message) => addNotification(message, { type: 'info' }),
		success: (message) => addNotification(message, { type: 'success' }),
		warning: (message) => addNotification(message, { type: 'warning' }),
		error: (message) => addNotification(message, { type: 'error' }),

		add: addNotification,
		dismiss,
		dismissAll
	}

	return setContext(NOTIFICATIONS_KEY, context)
}

export function getNotificationContext(): NotificationContext {
	if (!hasContext(NOTIFICATIONS_KEY)) {
		throw new Error('Notification context not found. Wrap with NotificationProvider.')
	}
	return getContext(NOTIFICATIONS_KEY)
}

Provider with UI:

<!-- NotificationProvider.svelte -->
<script lang="ts">
	import { createNotificationContext } from '$lib/context/notifications.svelte'
	import { fly, fade } from 'svelte/transition'

	let { children } = $props()

	const notifications = createNotificationContext()
</script>

{@render children()}

<!-- Notification toast container -->
{#if notifications.hasNotifications}
	<div class="notification-container" role="region" aria-label="Notifications">
		{#each notifications.all as notification (notification.id)}
			<div
				class="notification notification-{notification.type}"
				role="alert"
				in:fly={{ y: 20, duration: 200 }}
				out:fade={{ duration: 150 }}
			>
				<span class="message">{notification.message}</span>

				{#if notification.dismissible}
					<button
						class="dismiss"
						onclick={() => notifications.dismiss(notification.id)}
						aria-label="Dismiss"
					>
						×
					</button>
				{/if}
			</div>
		{/each}
	</div>
{/if}

<style>
	.notification-container {
		position: fixed;
		bottom: 1rem;
		right: 1rem;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		z-index: 9999;
	}

	.notification {
		display: flex;
		align-items: center;
		gap: 1rem;
		padding: 0.75rem 1rem;
		border-radius: 8px;
		background: white;
		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
		max-width: 400px;
	}

	.notification-success {
		border-left: 4px solid #10b981;
	}
	.notification-error {
		border-left: 4px solid #ef4444;
	}
	.notification-warning {
		border-left: 4px solid #f59e0b;
	}
	.notification-info {
		border-left: 4px solid #3b82f6;
	}

	.dismiss {
		padding: 0.25rem;
		border: none;
		background: none;
		font-size: 1.25rem;
		cursor: pointer;
		opacity: 0.5;
	}

	.dismiss:hover {
		opacity: 1;
	}
</style>

Usage throughout the app:

<script lang="ts">
	import { getNotificationContext } from '$lib/context/notifications.svelte'

	const notify = getNotificationContext()

	async function handleSave() {
		try {
			await saveDocument()
			notify.success('Document saved successfully!')
		} catch (err) {
			notify.error(`Failed to save: ${err.message}`)
		}
	}
</script>

<button onclick={handleSave}>Save Document</button>

{#if notify.hasNotifications}
	<span class="badge">{notify.count}</span>
{/if}

Quick Reference

PatternWhen to UseExample
GettersAlways for reading stateget value() { return count }
MethodsFor mutations with validationincrement() { if (canIncrement) count++ }
$derivedComputed values from statelet total = $derived(subtotal + tax)
$effectSide effects (persistence, sync)$effect(() => localStorage.setItem(key, value))
Symbol keysCollision-proof context keysconst KEY = Symbol('feature')
.svelte.tsReusable typed context modulesexport function createContext()

Conclusion

Reactive context patterns represent the maturation of state management in Svelte 5. The fundamentals—$state objects and the “mutate, don’t reassign” rule—provide the foundation. The patterns in this article—getters, $derived, $effect, and .svelte.ts modules—transform that foundation into production-quality architecture.

The progression is natural: you start by exposing raw state, then realize you need encapsulation (getters). You find yourself computing the same values repeatedly, then discover $derived handles it automatically. You need persistence or synchronization, and $effect provides the reactive hook. Finally, you want reuse and type safety, and .svelte.ts modules deliver.

What makes these patterns powerful is how they compose. A notification system might use all of them: getters for count and hasNotifications, $derived for filtering by type, $effect for auto-dismissal timers, and a .svelte.ts module for the complete typed API. Each pattern solves one problem well, and together they solve complex problems elegantly.

The investment in learning these patterns pays dividends in every context you build. Your APIs become cleaner, your code more maintainable, and your consumers—whether that’s you next month or a teammate next year—will thank you for the clarity.


Key Takeaways

  • Getters ensure current values—always expose reactive state through get value() { return state } rather than capturing values at creation time
  • Getters enable encapsulation—internal implementation can change without breaking consumers, as long as the getter interface stays the same
  • $derived handles computed values—Svelte automatically tracks dependencies and recalculates when they change, eliminating manual update logic
  • Derived values can chainsubtotal depends on items, total depends on subtotal and shipping—Svelte handles the dependency graph
  • $effect manages side effects—use for persistence, external synchronization, DOM updates, and analytics; return cleanup functions for listeners and timers
  • Separate concerns across multiple effects—each effect should have a single responsibility for clarity and maintainability
  • .svelte.ts modules enable reuse—create typed context modules with Symbol keys, creation functions, and typed getters for professional APIs
  • Export complete APIs—provide creation functions, required and optional getters, and clear TypeScript interfaces

What’s Next

Take encapsulation further with Class-Based Context, where TypeScript classes combine with runes for powerful state management, private fields, and inheritance patterns.


See Also

Official Documentation