When Plain Objects Strain

You’ve learned to make context reactive with $state objects and getter functions. You’ve seen how $derived handles computed values and $effect manages side effects. These patterns work beautifully—until your context grows complex enough that organizing it becomes the challenge.

When a context has a dozen state fields, multiple computed properties, several action methods, and initialization logic that depends on parameters, the flat object approach starts to strain. Related pieces drift apart. It becomes unclear which methods modify which state. Adding new features means hunting through scattered code to find where things belong.

TypeScript classes offer a solution. A class naturally groups related state and behavior. Fields live at the top, methods follow, and the relationship between them is explicit. More importantly, classes provide features that plain objects can’t match: true private fields that TypeScript enforces, constructor initialization with validation, inheritance for shared behavior, and rich IDE support that understands your code’s structure.

The best part? Svelte 5 runes work perfectly inside classes. You use $state, $derived, and $effect exactly as you would anywhere else—the class just provides organization and encapsulation.

This article covers class-based context from practical basics to advanced patterns. You’ll learn when classes help, how to structure them effectively, and how to avoid the pitfalls that trip up developers new to this approach.


The Problem Space

Before adding classes, let’s understand when they actually help. Not every context needs a class—but some clearly benefit.

When Objects Become Unwieldy

Consider a user preferences context that’s grown over time:

// preferences-context.svelte.ts
export function createPreferencesContext() {
	// State scattered across multiple declarations
	let theme = $state<'light' | 'dark' | 'system'>('system')
	let fontSize = $state(16)
	let language = $state('en')
	let reducedMotion = $state(false)
	let notifications = $state(true)
	let emailDigest = $state<'daily' | 'weekly' | 'never'>('weekly')
	let timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone)
	let dateFormat = $state<'mdy' | 'dmy' | 'ymd'>('mdy')
	let measurementUnit = $state<'metric' | 'imperial'>('metric')

	// Derived values somewhere in the middle
	let effectiveTheme = $derived(
		theme === 'system'
			? window.matchMedia('(prefers-color-scheme: dark)').matches
				? 'dark'
				: 'light'
			: theme
	)

	let localeSettings = $derived({
		language,
		timezone,
		dateFormat,
		measurementUnit
	})

	// Effects for persistence scattered around
	$effect(() => {
		localStorage.setItem(
			'preferences',
			JSON.stringify({
				theme,
				fontSize,
				language,
				reducedMotion,
				notifications,
				emailDigest,
				timezone,
				dateFormat,
				measurementUnit
			})
		)
	})

	$effect(() => {
		document.documentElement.setAttribute('data-theme', effectiveTheme)
	})

	// Methods at the end
	return {
		get theme() {
			return theme
		},
		get fontSize() {
			return fontSize
		},
		get language() {
			return language
		},
		// ... 15 more getters

		setTheme(value: 'light' | 'dark' | 'system') {
			theme = value
		},
		setFontSize(value: number) {
			fontSize = Math.max(12, Math.min(24, value))
		},
		setLanguage(value: string) {
			language = value
		},
		// ... 10 more setters

		resetToDefaults() {
			theme = 'system'
			fontSize = 16
			language = 'en'
			// ... reset all 9 fields
		}
	}
}

This works, but it’s becoming difficult to navigate. State declarations are at the top, derived values somewhere in the middle, effects scattered around, and the returned object at the bottom. When you need to add a new preference, you have to modify four different places.

What Classes Provide

A class version of the same context:

// PreferencesContext.svelte.ts
export class PreferencesContext {
	// All state together at the top
	theme = $state<'light' | 'dark' | 'system'>('system')
	fontSize = $state(16)
	language = $state('en')
	reducedMotion = $state(false)
	notifications = $state(true)
	emailDigest = $state<'daily' | 'weekly' | 'never'>('weekly')
	timezone = $state(Intl.DateTimeFormat().resolvedOptions().timeZone)
	dateFormat = $state<'mdy' | 'dmy' | 'ymd'>('mdy')
	measurementUnit = $state<'metric' | 'imperial'>('metric')

	// Computed properties clearly marked
	get effectiveTheme() {
		return this.theme === 'system'
			? window.matchMedia('(prefers-color-scheme: dark)').matches
				? 'dark'
				: 'light'
			: this.theme
	}

	get localeSettings() {
		return {
			language: this.language,
			timezone: this.timezone,
			dateFormat: this.dateFormat,
			measurementUnit: this.measurementUnit
		}
	}

	// Methods grouped logically
	setTheme(value: 'light' | 'dark' | 'system') {
		this.theme = value
	}

	setFontSize(value: number) {
		this.fontSize = Math.max(12, Math.min(24, value))
	}

	resetToDefaults() {
		this.theme = 'system'
		this.fontSize = 16
		this.language = 'en'
		this.reducedMotion = false
		this.notifications = true
		this.emailDigest = 'weekly'
		this.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
		this.dateFormat = 'mdy'
		this.measurementUnit = 'metric'
	}

	// Lifecycle setup in constructor
	constructor() {
		// Load from localStorage
		this.loadFromStorage()

		// Set up persistence
		$effect(() => {
			this.saveToStorage()
		})

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

	private loadFromStorage() {
		try {
			const stored = localStorage.getItem('preferences')
			if (stored) {
				const data = JSON.parse(stored)
				Object.assign(this, data)
			}
		} catch {
			// Use defaults on error
		}
	}

	private saveToStorage() {
		localStorage.setItem(
			'preferences',
			JSON.stringify({
				theme: this.theme,
				fontSize: this.fontSize,
				language: this.language,
				reducedMotion: this.reducedMotion,
				notifications: this.notifications,
				emailDigest: this.emailDigest,
				timezone: this.timezone,
				dateFormat: this.dateFormat,
				measurementUnit: this.measurementUnit
			})
		)
	}
}

The class version is easier to navigate: state at the top, computed properties next, public methods, constructor, then private helpers. When you need to add a new preference, the structure tells you exactly where each piece goes.


The Svelte 5 Mental Model

Svelte 5 runes work inside classes because runes are compile-time transformations, not runtime features. When Svelte sees $state, $derived, or $effect, it transforms that code during compilation. It doesn’t matter whether the code is inside a function, an object method, or a class—the transformation applies the same way.

Runes in Class Fields

Class fields with $state become reactive:

class Counter {
	// This field is reactive
	count = $state(0)

	// Methods can read and mutate the reactive field
	increment() {
		this.count++ // Svelte tracks this mutation
	}
}

When Svelte compiles this, it transforms the count field into something that Svelte’s reactivity system can track. The transformation is invisible to you—you just write normal-looking class code.

Derived Values as Getters

For computed values in classes, use native JavaScript getters:

class Counter {
	count = $state(0)

	// Getter computes fresh each time it's accessed
	get doubled() {
		return this.count * 2
	}

	// Getter can derive from other getters
	get quadrupled() {
		return this.doubled * 2
	}
}

Getters work like $derived—they compute their value each time they’re accessed, and Svelte tracks which reactive values they read.

Getters vs $derived in Classes

In classes, JavaScript getters serve the same purpose as $derived. Both compute values reactively. Getters are the idiomatic choice for classes because they integrate naturally with class syntax and TypeScript’s type inference.

Effects in Constructors

Place $effect calls in the constructor to set up side effects when the class is instantiated:

class Timer {
	seconds = $state(0)
	running = $state(false)

	constructor() {
		$effect(() => {
			if (this.running) {
				const interval = setInterval(() => {
					this.seconds++
				}, 1000)

				// Cleanup when effect re-runs or component unmounts
				return () => clearInterval(interval)
			}
		})
	}

	start() {
		this.running = true
	}

	stop() {
		this.running = false
	}
}

The effect runs during component initialization (when the class is instantiated in a component’s <script>) and cleans up appropriately.


Implementation

Let’s build class-based context progressively, starting simple and adding complexity.

Step 1: Basic Class with $state

The simplest class-based context bundles related state and methods:

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

const COUNTER_KEY = Symbol('counter')

export class CounterContext {
	count = $state(0)

	increment() {
		this.count++
	}

	decrement() {
		this.count--
	}

	reset() {
		this.count = 0
	}
}

export function setCounterContext() {
	return setContext(COUNTER_KEY, new CounterContext())
}

export function getCounterContext(): CounterContext {
	return getContext(COUNTER_KEY)
}

Using it:

<!-- Provider.svelte -->
<script>
	import { setCounterContext } from './counter.svelte'
	import type { Snippet } from 'svelte'

	let { children }: { children: Snippet } = $props()

	setCounterContext()
</script>

{@render children()}
<!-- Consumer.svelte -->
<script>
	import { getCounterContext } from './counter.svelte'

	const counter = getCounterContext()
</script>

<p>Count: {counter.count}</p>
<button onclick={() => counter.increment()}>+</button>
<button onclick={() => counter.decrement()}>-</button>
<button onclick={() => counter.reset()}>Reset</button>

Step 2: Adding Computed Properties

Add getters for derived values:

// counter.svelte.ts
export class CounterContext {
	count = $state(0)
	step = $state(1)

	// Computed properties via getters
	get isPositive() {
		return this.count > 0
	}

	get isNegative() {
		return this.count < 0
	}

	get isZero() {
		return this.count === 0
	}

	get displayValue() {
		const sign = this.count >= 0 ? '' : '-'
		return `${sign}${Math.abs(this.count).toLocaleString()}`
	}

	increment() {
		this.count += this.step
	}

	decrement() {
		this.count -= this.step
	}

	setStep(value: number) {
		this.step = Math.max(1, value)
	}

	reset() {
		this.count = 0
	}
}

Consumers access computed properties like regular properties:

<script>
	const counter = getCounterContext()
</script>

<p class:positive={counter.isPositive} class:negative={counter.isNegative}>
	{counter.displayValue}
</p>

Step 3: Constructor Parameters

Use constructors to configure class instances:

// counter.svelte.ts
export interface CounterOptions {
	initial?: number
	min?: number
	max?: number
	step?: number
}

export class CounterContext {
	count: number
	readonly min: number
	readonly max: number
	step: number

	constructor(options: CounterOptions = {}) {
		const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options

		this.count = $state(Math.max(min, Math.min(max, initial)))
		this.min = min
		this.max = max
		this.step = $state(step)
	}

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

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

	increment() {
		if (this.canIncrement) {
			this.count += this.step
		}
	}

	decrement() {
		if (this.canDecrement) {
			this.count -= this.step
		}
	}

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

export function setCounterContext(options?: CounterOptions) {
	return setContext(COUNTER_KEY, new CounterContext(options))
}

Provider with configuration:

<script>
	import { setCounterContext } from './counter.svelte'
	import type { Snippet } from 'svelte'

	let { children }: { children: Snippet } = $props()

	// Counter that starts at 50, can't go below 0 or above 100
	setCounterContext({ initial: 50, min: 0, max: 100, step: 5 })
</script>

{@render children()}

Step 4: Private Fields for Encapsulation

TypeScript private fields prevent consumers from directly accessing internal state:

// user-session.svelte.ts
export class UserSessionContext {
	// Private fields - inaccessible outside the class
	#user = $state<User | null>(null)
	#token = $state<string | null>(null)
	#refreshTimer: ReturnType<typeof setTimeout> | null = null

	// Public getters provide controlled access
	get isAuthenticated() {
		return this.#user !== null && this.#token !== null
	}

	get user() {
		return this.#user
	}

	get userId() {
		return this.#user?.id ?? null
	}

	get username() {
		return this.#user?.username ?? null
	}

	// Token is never exposed - only used internally

	constructor() {
		// Attempt to restore session on creation
		this.#restoreSession()
	}

	async login(credentials: { email: string; password: string }) {
		const response = await fetch('/api/auth/login', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(credentials)
		})

		if (!response.ok) {
			throw new Error('Login failed')
		}

		const data = await response.json()
		this.#user = data.user
		this.#token = data.token

		// Store for session restoration
		sessionStorage.setItem('auth_token', data.token)

		// Set up automatic token refresh
		this.#scheduleTokenRefresh()
	}

	logout() {
		this.#user = null
		this.#token = null
		sessionStorage.removeItem('auth_token')

		if (this.#refreshTimer) {
			clearTimeout(this.#refreshTimer)
			this.#refreshTimer = null
		}
	}

	// Private helper methods
	#restoreSession() {
		const token = sessionStorage.getItem('auth_token')
		if (token) {
			this.#token = token
			this.#fetchCurrentUser()
		}
	}

	async #fetchCurrentUser() {
		if (!this.#token) return

		try {
			const response = await fetch('/api/auth/me', {
				headers: { Authorization: `Bearer ${this.#token}` }
			})

			if (response.ok) {
				this.#user = await response.json()
				this.#scheduleTokenRefresh()
			} else {
				this.logout()
			}
		} catch {
			this.logout()
		}
	}

	#scheduleTokenRefresh() {
		// Refresh token 5 minutes before expiry
		this.#refreshTimer = setTimeout(
			() => {
				this.#refreshToken()
			},
			55 * 60 * 1000
		) // 55 minutes
	}

	async #refreshToken() {
		if (!this.#token) return

		try {
			const response = await fetch('/api/auth/refresh', {
				method: 'POST',
				headers: { Authorization: `Bearer ${this.#token}` }
			})

			if (response.ok) {
				const data = await response.json()
				this.#token = data.token
				sessionStorage.setItem('auth_token', data.token)
				this.#scheduleTokenRefresh()
			} else {
				this.logout()
			}
		} catch {
			this.logout()
		}
	}
}

With private fields, consumers can’t accidentally (or intentionally) access the token directly:

<script>
	const session = getUserSessionContext()

	// ✅ These work
	console.log(session.isAuthenticated)
	console.log(session.username)

	// ❌ TypeScript error: Property '#token' is not accessible
	// console.log(session.#token)
</script>

Step 5: Effects for Lifecycle Management

Add $effect in the constructor for side effects:

// theme.svelte.ts
export class ThemeContext {
	#mode = $state<'light' | 'dark' | 'system'>('system')
	#systemPreference = $state<'light' | 'dark'>('light')

	get mode() {
		return this.#mode
	}

	get effectiveTheme(): 'light' | 'dark' {
		if (this.#mode === 'system') {
			return this.#systemPreference
		}
		return this.#mode
	}

	get isDark() {
		return this.effectiveTheme === 'dark'
	}

	constructor() {
		// Load saved preference
		const saved = localStorage.getItem('theme-mode')
		if (saved === 'light' || saved === 'dark' || saved === 'system') {
			this.#mode = saved
		}

		// Track system preference
		if (typeof window !== 'undefined') {
			const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
			this.#systemPreference = mediaQuery.matches ? 'dark' : 'light'

			// Effect to listen for system preference changes
			$effect(() => {
				const handler = (e: MediaQueryListEvent) => {
					this.#systemPreference = e.matches ? 'dark' : 'light'
				}

				mediaQuery.addEventListener('change', handler)
				return () => mediaQuery.removeEventListener('change', handler)
			})
		}

		// Effect to persist mode changes
		$effect(() => {
			localStorage.setItem('theme-mode', this.#mode)
		})

		// Effect to apply theme to document
		$effect(() => {
			document.documentElement.setAttribute('data-theme', this.effectiveTheme)
			document.documentElement.classList.toggle('dark', this.isDark)
		})
	}

	setMode(mode: 'light' | 'dark' | 'system') {
		this.#mode = mode
	}

	toggle() {
		if (this.#mode === 'light') {
			this.#mode = 'dark'
		} else if (this.#mode === 'dark') {
			this.#mode = 'system'
		} else {
			this.#mode = 'light'
		}
	}
}

Step 6: Composition Over Inheritance

When contexts share behavior, prefer composition over inheritance. This keeps classes focused and testable:

// Reusable behavior modules
// persistence.svelte.ts
export function createPersistence<T>(
	key: string,
	serialize: (data: T) => string,
	deserialize: (data: string) => T
) {
	return {
		load(): T | null {
			try {
				const stored = localStorage.getItem(key)
				return stored ? deserialize(stored) : null
			} catch {
				return null
			}
		},

		save(data: T) {
			localStorage.setItem(key, serialize(data))
		},

		clear() {
			localStorage.removeItem(key)
		}
	}
}

// history.svelte.ts
export class History<T> {
	#states = $state<T[]>([])
	#index = $state(-1)
	#maxSize: number

	constructor(maxSize = 50) {
		this.#maxSize = maxSize
	}

	get canUndo() {
		return this.#index > 0
	}

	get canRedo() {
		return this.#index < this.#states.length - 1
	}

	get current(): T | undefined {
		return this.#states[this.#index]
	}

	push(state: T) {
		// Remove any future states if we're not at the end
		this.#states = this.#states.slice(0, this.#index + 1)

		// Add new state
		this.#states.push(state)

		// Trim to max size
		if (this.#states.length > this.#maxSize) {
			this.#states = this.#states.slice(-this.#maxSize)
		}

		this.#index = this.#states.length - 1
	}

	undo(): T | undefined {
		if (this.canUndo) {
			this.#index--
			return this.current
		}
	}

	redo(): T | undefined {
		if (this.canRedo) {
			this.#index++
			return this.current
		}
	}

	clear() {
		this.#states = []
		this.#index = -1
	}
}

Now compose these into a context:

// document-context.svelte.ts
export class DocumentContext {
	#content = $state('')
	#title = $state('Untitled')
	#savedAt = $state<Date | null>(null)

	// Composed behaviors
	#history = new History<string>(100)
	#persistence = createPersistence<{ title: string; content: string }>(
		'document',
		JSON.stringify,
		JSON.parse
	)

	get content() {
		return this.#content
	}

	get title() {
		return this.#title
	}

	get savedAt() {
		return this.#savedAt
	}

	get canUndo() {
		return this.#history.canUndo
	}

	get canRedo() {
		return this.#history.canRedo
	}

	get hasUnsavedChanges() {
		const saved = this.#persistence.load()
		return !saved || saved.content !== this.#content || saved.title !== this.#title
	}

	constructor() {
		// Load saved document
		const saved = this.#persistence.load()
		if (saved) {
			this.#content = saved.content
			this.#title = saved.title
		}

		// Initialize history with current content
		this.#history.push(this.#content)
	}

	setContent(content: string) {
		this.#content = content
		this.#history.push(content)
	}

	setTitle(title: string) {
		this.#title = title
	}

	undo() {
		const previous = this.#history.undo()
		if (previous !== undefined) {
			this.#content = previous
		}
	}

	redo() {
		const next = this.#history.redo()
		if (next !== undefined) {
			this.#content = next
		}
	}

	save() {
		this.#persistence.save({
			title: this.#title,
			content: this.#content
		})
		this.#savedAt = new Date()
	}

	clear() {
		this.#content = ''
		this.#title = 'Untitled'
		this.#history.clear()
		this.#history.push('')
		this.#persistence.clear()
		this.#savedAt = null
	}
}

Composition keeps each piece focused. The History class handles undo/redo. The persistence module handles storage. The DocumentContext orchestrates them without mixing concerns.


Common Mistakes and Anti-Patterns

When using class-based context, certain pitfalls can arise. Here are common mistakes and how to avoid them.

1: Mistake: Losing this Context

class Counter {
	count = $state(0)

	// ❌ Arrow function loses `this` context in some scenarios
	increment = () => {
		this.count++
	}

	// ❌ Destructuring breaks `this` binding
	// const { increment } = counter; increment() // Error!
}

Why it happens: When methods are destructured or passed as callbacks, they lose their this binding.

Fix: Use regular methods and bind at the call site, or use arrow functions consistently with awareness of the tradeoffs:

class Counter {
  count = $state(0)

  // ✅ Regular method - works when called on the instance
  increment() {
    this.count++
  }
}

// In component: always call on the instance
<button onclick={() => counter.increment()}>+</button>

2: Creating Classes in the Wrong Place

<!-- ❌ Creates new instance every render -->
<script>
	class Counter {
		count = $state(0)
	}

	const counter = new Counter() // Recreated on each render!
</script>

Why it happens: The class is defined and instantiated inside the component, which runs during initialization.

Fix: Define classes in separate .svelte.ts files:

// counter.svelte.ts
export class Counter {
	count = $state(0)
	increment() {
		this.count++
	}
}
<!-- Component.svelte -->
<script>
	import { Counter } from './counter.svelte'

	const counter = new Counter() // Created once during initialization
</script>

3: Exposing Mutable Arrays or Objects

class TodoContext {
	// ❌ Consumers can mutate the array directly
	todos = $state<Todo[]>([])

	addTodo(text: string) {
		this.todos.push({ id: Date.now(), text, done: false })
	}
}

// Consumer can break encapsulation:
// context.todos.push({ id: 'fake', text: 'hacked', done: true })

Why it happens: Arrays and objects are references. Exposing them lets consumers bypass your methods.

Fix: Use private fields with readonly getters:

class TodoContext {
	#todos = $state<Todo[]>([])

	// Return a copy or use a getter that TypeScript sees as readonly
	get todos(): readonly Todo[] {
		return this.#todos
	}

	addTodo(text: string) {
		this.#todos.push({ id: Date.now(), text, done: false })
	}
}

4: Using $derived in Class Fields

class Counter {
	count = $state(0)

	// ❌ This doesn't work as expected in class fields
	doubled = $derived(this.count * 2) // 'this' may not be bound correctly
}

Why it happens: Class field initialization happens before the constructor runs, and this binding can be problematic with $derived in field initializers.

Fix: Use native getters instead:

class Counter {
	count = $state(0)

	// ✅ Getter works correctly
	get doubled() {
		return this.count * 2
	}
}

5: Heavy Constructor Logic

class DataContext {
	data = $state<Data[]>([])

	constructor() {
		// ❌ Blocking async operations in constructor
		const response = await fetch('/api/data') // Syntax error!
		this.data = await response.json()
	}
}

Why it happens: Constructors can’t be async, and blocking operations cause problems.

Fix: Use an explicit initialization method or initialize via effect:

class DataContext {
	#data = $state<Data[]>([])
	#loading = $state(false)
	#error = $state<Error | null>(null)

	get data() {
		return this.#data
	}
	get loading() {
		return this.#loading
	}
	get error() {
		return this.#error
	}

	constructor() {
		// Start loading in an effect
		$effect(() => {
			this.load()
		})
	}

	async load() {
		this.#loading = true
		this.#error = null

		try {
			const response = await fetch('/api/data')
			if (!response.ok) throw new Error('Failed to fetch')
			this.#data = await response.json()
		} catch (e) {
			this.#error = e instanceof Error ? e : new Error('Unknown error')
		} finally {
			this.#loading = false
		}
	}
}

Performance and Scaling Considerations

Granular Reactivity

Svelte’s reactivity is granular at the property level. When you have a class with multiple $state fields, changing one field only affects components that read that specific field:

class AppContext {
	user = $state<User | null>(null)
	theme = $state<'light' | 'dark'>('light')
	notifications = $state<Notification[]>([])
}

A component that only reads context.theme won’t re-render when context.notifications changes. This means large context classes don’t inherently cause performance issues—only the relevant parts trigger updates.

Avoiding Unnecessary Getters

Simple property access doesn’t need getter overhead:

class Counter {
	count = $state(0)

	// ❌ Unnecessary getter for simple state
	get value() {
		return this.count
	}

	// ✅ Just expose the field directly
	// Consumers use: counter.count
}

Use getters when you need:

  • Computed/derived values
  • Access control (returning copies of arrays/objects)
  • Lazy evaluation of expensive operations

Large Collections

For contexts managing large collections (hundreds of items), consider:

class ItemsContext {
	#items = $state<Map<string, Item>>(new Map())

	get items(): readonly Item[] {
		return Array.from(this.#items.values())
	}

	getById(id: string): Item | undefined {
		return this.#items.get(id) // O(1) lookup
	}

	updateItem(id: string, updates: Partial<Item>) {
		const item = this.#items.get(id)
		if (item) {
			// Mutate in place for reactivity
			Object.assign(item, updates)
		}
	}

	// For bulk operations, batch updates
	bulkUpdate(updates: Map<string, Partial<Item>>) {
		for (const [id, changes] of updates) {
			this.updateItem(id, changes)
		}
	}
}

Using a Map provides O(1) lookups. Mutating items in place maintains reactivity without recreating the entire collection.

Memory Management

Classes persist for the lifetime of their provider component. Be mindful of:

class CacheContext {
	#cache = $state<Map<string, CachedItem>>(new Map())
	#maxSize = 1000

	set(key: string, value: unknown, ttl = 60000) {
		// Evict if at capacity
		if (this.#cache.size >= this.#maxSize) {
			const oldestKey = this.#cache.keys().next().value
			if (oldestKey) this.#cache.delete(oldestKey)
		}

		this.#cache.set(key, {
			value,
			expiresAt: Date.now() + ttl
		})
	}

	get(key: string): unknown | undefined {
		const item = this.#cache.get(key)
		if (!item) return undefined

		if (Date.now() > item.expiresAt) {
			this.#cache.delete(key)
			return undefined
		}

		return item.value
	}

	// Periodic cleanup
	constructor() {
		$effect(() => {
			const interval = setInterval(() => {
				const now = Date.now()
				for (const [key, item] of this.#cache) {
					if (now > item.expiresAt) {
						this.#cache.delete(key)
					}
				}
			}, 60000)

			return () => clearInterval(interval)
		})
	}
}

When NOT to Use Classes

Classes add structure but also ceremony. Don’t use them when:

Simple Context

For simple contexts with one or two state fields and minimal logic, a plain object is cleaner:

// ❌ Overkill for simple context
class ToggleContext {
	value = $state(false)
	toggle() {
		this.value = !this.value
	}
}

// ✅ Simple object is better
export function createToggleContext() {
	let value = $state(false)

	return setContext('toggle', {
		get value() {
			return value
		},
		toggle() {
			value = !value
		}
	})
}

Singleton State

If you have application-wide state that doesn’t need the context API (not dependent on component tree position), consider a simple module:

// global-state.svelte.ts
// ✅ Simpler for true globals
export const appState = {
	#darkMode: $state(false),

	get darkMode() {
		return this.#darkMode
	},
	toggleDarkMode() {
		this.#darkMode = !this.#darkMode
	}
}

// Use directly without context:
// import { appState } from './global-state.svelte'

Stateless Utilities

Don’t wrap stateless utilities in classes:

// ❌ Class with no state is just functions with extra steps
class Validators {
	isEmail(value: string) {
		return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
	}
}

// ✅ Just use functions
export function isEmail(value: string) {
	return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

Deeply Nested Inheritance

Avoid deep inheritance hierarchies. They’re hard to understand and maintain:

// ❌ Deep inheritance is confusing
class BaseContext {
	/* ... */
}
class StatefulContext extends BaseContext {
	/* ... */
}
class PersistentContext extends StatefulContext {
	/* ... */
}
class UserContext extends PersistentContext {
	/* ... */
}

// ✅ Prefer composition
class UserContext {
	#persistence = new Persistence('user')
	#state = new StateMachine(/* ... */)
	// ...
}

Complete Production Example

Here’s a complete notification system demonstrating all the patterns:

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

const NOTIFICATIONS_KEY = Symbol('notifications')

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

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

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

export class NotificationsContext {
	// Private state
	#notifications = $state<Notification[]>([])
	#timers = new Map<string, ReturnType<typeof setTimeout>>()

	// Configuration
	readonly #defaultDuration: number
	readonly #maxNotifications: number

	constructor(options: { defaultDuration?: number; maxNotifications?: number } = {}) {
		this.#defaultDuration = options.defaultDuration ?? 5000
		this.#maxNotifications = options.maxNotifications ?? 5
	}

	// Public getters
	get all(): readonly Notification[] {
		return this.#notifications
	}

	get count() {
		return this.#notifications.length
	}

	get hasNotifications() {
		return this.#notifications.length > 0
	}

	// Convenience methods for common notification types
	info(message: string, options?: Omit<NotificationOptions, 'type'>) {
		return this.add(message, { ...options, type: 'info' })
	}

	success(message: string, options?: Omit<NotificationOptions, 'type'>) {
		return this.add(message, { ...options, type: 'success' })
	}

	warning(message: string, options?: Omit<NotificationOptions, 'type'>) {
		return this.add(message, { ...options, type: 'warning' })
	}

	error(message: string, options?: Omit<NotificationOptions, 'type'>) {
		// Errors stay longer and don't auto-dismiss by default
		return this.add(message, { duration: 0, ...options, type: 'error' })
	}

	// Core add method
	add(message: string, options: NotificationOptions = {}): string {
		const { type = 'info', duration = this.#defaultDuration, dismissible = true } = options

		const id = this.#generateId()

		const notification: Notification = {
			id,
			message,
			type,
			timestamp: Date.now(),
			dismissible
		}

		// Add to the beginning (newest first)
		this.#notifications.unshift(notification)

		// Enforce max notifications
		while (this.#notifications.length > this.#maxNotifications) {
			const oldest = this.#notifications.pop()
			if (oldest) {
				this.#clearTimer(oldest.id)
			}
		}

		// Set up auto-dismiss if duration > 0
		if (duration > 0) {
			const timer = setTimeout(() => {
				this.dismiss(id)
			}, duration)
			this.#timers.set(id, timer)
		}

		return id
	}

	// Dismiss a specific notification
	dismiss(id: string) {
		const index = this.#notifications.findIndex((n) => n.id === id)
		if (index !== -1) {
			this.#notifications.splice(index, 1)
			this.#clearTimer(id)
		}
	}

	// Dismiss all notifications
	dismissAll() {
		for (const notification of this.#notifications) {
			this.#clearTimer(notification.id)
		}
		this.#notifications.length = 0
	}

	// Dismiss all notifications of a specific type
	dismissByType(type: NotificationType) {
		const toDismiss = this.#notifications.filter((n) => n.type === type).map((n) => n.id)

		for (const id of toDismiss) {
			this.dismiss(id)
		}
	}

	// Private helpers
	#generateId(): string {
		return `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
	}

	#clearTimer(id: string) {
		const timer = this.#timers.get(id)
		if (timer) {
			clearTimeout(timer)
			this.#timers.delete(id)
		}
	}
}

// Context setup helpers
export function setNotificationsContext(
	options?: ConstructorParameters<typeof NotificationsContext>[0]
) {
	return setContext(NOTIFICATIONS_KEY, new NotificationsContext(options))
}

export function getNotificationsContext(): NotificationsContext {
	if (!hasContext(NOTIFICATIONS_KEY)) {
		throw new Error(
			'Notifications context not found. ' + 'Wrap your component tree with a NotificationsProvider.'
		)
	}
	return getContext(NOTIFICATIONS_KEY)
}

Provider component with UI:

<!-- NotificationsProvider.svelte -->
<script lang="ts">
	import { setNotificationsContext } from './notifications-context.svelte'
	import { fly, fade } from 'svelte/transition'
	import type { Snippet } from 'svelte'

	interface Props {
		defaultDuration?: number
		maxNotifications?: number
		children: Snippet
	}

	let { defaultDuration = 5000, maxNotifications = 5, children }: Props = $props()

	const notifications = setNotificationsContext({
		defaultDuration,
		maxNotifications
	})
</script>

{@render children()}

<!-- Toast container -->
{#if notifications.hasNotifications}
	<div class="notifications-container" role="region" aria-label="Notifications" aria-live="polite">
		{#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="notification-message">{notification.message}</span>

				{#if notification.dismissible}
					<button
						class="notification-dismiss"
						onclick={() => notifications.dismiss(notification.id)}
						aria-label="Dismiss notification"
					>
						<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
							<path d="M18 6L6 18M6 6l12 12" />
						</svg>
					</button>
				{/if}
			</div>
		{/each}
	</div>
{/if}

<style>
	.notifications-container {
		position: fixed;
		top: 1rem;
		right: 1rem;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		z-index: 9999;
		max-width: 400px;
	}

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

	.notification-info {
		border-left-color: #3b82f6;
	}

	.notification-success {
		border-left-color: #10b981;
	}

	.notification-warning {
		border-left-color: #f59e0b;
	}

	.notification-error {
		border-left-color: #ef4444;
	}

	.notification-message {
		flex: 1;
		font-size: 0.9375rem;
		line-height: 1.5;
		color: #1e293b;
	}

	.notification-dismiss {
		flex-shrink: 0;
		width: 20px;
		height: 20px;
		padding: 0;
		border: none;
		background: none;
		color: #94a3b8;
		cursor: pointer;
		transition: color 0.2s;
	}

	.notification-dismiss:hover {
		color: #475569;
	}

	.notification-dismiss svg {
		width: 100%;
		height: 100%;
	}
</style>

Usage throughout the application:

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

	const notifications = getNotificationsContext()

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

	function handleFormSubmit() {
		if (!validateForm()) {
			notifications.warning('Please fill in all required fields')
			return
		}
		// ... proceed with submission
	}
</script>

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

Conclusion

Class-based context represents the natural evolution of state management patterns as complexity grows. When context has multiple related state fields, computed properties, action methods, and lifecycle concerns, classes provide the organizational structure that keeps code maintainable.

The key insight is that Svelte 5 runes work seamlessly inside classes. $state creates reactive fields. JavaScript getters serve as computed properties (similar to $derived). $effect in constructors handles lifecycle setup. Private fields enforce encapsulation. TypeScript provides type safety and IDE support.

But classes aren’t always the answer. Simple contexts with one or two state fields are better served by plain objects. True global state might not need context at all. Stateless utilities should just be functions. The goal is matching the solution to the problem’s complexity—no more, no less.

When you do reach for classes, the patterns in this article will serve you well: private fields for encapsulation, getters for computed values, constructor parameters for configuration, composed behaviors for reusability, and explicit typing for safety.


Key Takeaways

Classes organize complex context. When context has many related state fields, methods, and computed values, a class provides clear structure that plain objects lack.

Runes work inside classes. Use $state for reactive fields, JavaScript getters for computed properties, and $effect in constructors for lifecycle management. The syntax is familiar; the class just provides organization.

Private fields enforce encapsulation. TypeScript’s #field syntax creates truly private state that consumers cannot access directly, forcing them to use your public API.

Getters replace $derived. In class contexts, native JavaScript getters serve the same purpose as $derived—they compute values reactively without the need for the rune.

Constructors initialize but shouldn’t block. Use constructors to set up initial state and effects, but handle async operations through explicit methods or effects that run after construction.

Composition beats inheritance. Build reusable behavior modules (persistence, history, validation) and compose them into context classes rather than creating deep inheritance hierarchies.

Match complexity to needs. Simple contexts don’t need classes. Use plain objects for one or two state fields. Reserve classes for contexts where the organizational benefits outweigh the ceremony.


What’s Next

Ready to apply class-based context to real-world features? The next article, Context as a Feature Boundary, shows how to encapsulate entire features—like shopping carts, authentication, and notifications—behind clean, testable APIs. You’ll learn to replace global stores, compose features safely, and scale your architecture as your app grows.


See Also

Official Documentation