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 ClassesIn 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
- Svelte 5 Classes — Using runes in classes
- TypeScript Class Fields — Private fields and access modifiers
Related Articles
- Making Context Reactive — Foundation for reactive context patterns
- Reactive Context Patterns — Getters,
$derived, and$effectpatterns - Context Best Practices — Organization and testing strategies
- Compound Components — Advanced component composition