Many Mechanisms, One Decision

Svelte provides multiple mechanisms for sharing data between components: props, context, stores, events, module-level state, and integration points with SvelteKit’s data loading. Each exists for specific reasons, excels in particular scenarios, and carries distinct trade-offs. Rather than viewing these as competing options where one is “better,” understanding each pattern’s strengths helps you select the right tool for each situation.

This comprehensive guide presents a balanced comparison of all state management patterns in Svelte 5. You’ll see the same problems solved with different approaches, understand the trade-offs involved through visual diagrams, and develop intuition for when each pattern shines. Whether you’re building your first Svelte application or architecting a complex production system, this guide provides the decision framework you need.

By the end, you’ll have clarity on when context shines, when stores make more sense, when to stick with simple props, and when you might reach for something else entirely.


The State Management Landscape

Before diving into comparisons, let’s establish what each pattern offers and how they relate to each other. Understanding this landscape is the foundation for making good architectural decisions.

Pattern Overview

PatternScopeDirectionReactivitySSR Safety
PropsParent → direct childDown (one level)Automatic✅ Safe
ContextAncestor → any descendantDown (multiple levels)Manual ($state)✅ Safe
StoresAnywhere in module scopeAny directionBuilt-in⚠️ Requires care
EventsChild → parentUp (one level)N/A✅ Safe
Bindable PropsParent ↔ childBidirectionalAutomatic✅ Safe
Module StateAll importersAny directionManual ($state)❌ Dangerous
SvelteKit LoadRoute → page/layoutServer → ClientVia props✅ Safe

Each row represents a fundamentally different approach to the same problem: getting data from where it exists to where it’s needed.

Visual Architecture

The following diagram shows how data flows through different patterns in a typical Svelte application:

Loading diagram...

The solid arrows represent explicit data flow (props, events), while dashed arrows represent implicit flow (context). Notice how context “tunnels” through intermediate components, while props must pass through each level.


Understanding the Mental Models

Before comparing patterns, let’s understand how each one conceptually moves data through your application. This mental model is essential for choosing the right pattern.

Data Flow Directions

Loading diagram...

Each pattern serves a specific communication need:

Props are explicit contracts between parent and child. The child declares what it needs, and the parent provides it. This explicitness makes components self-documenting but requires threading data through every intermediate component.

Context creates an implicit channel from any ancestor to any descendant. Intermediate components don’t need to know about or forward the data. This solves prop drilling but introduces hidden dependencies.

Events (callback props in Svelte 5) notify parents of actions in children. They’re the complement to props, completing the parent-child communication loop.

Stores break out of the component tree entirely. Any code that imports a store shares the same reactive state, enabling patterns impossible with tree-scoped communication.

Scope Visualization

Understanding scope is critical, especially for SSR safety:

Loading diagram...

This diagram illustrates why module-level state is dangerous in SSR: both Request A and Request B share the same module state, potentially leaking data between users. Context creates isolated instances per component tree, making it inherently SSR-safe.


Context vs Props

This is the comparison most developers encounter first. When should you reach for context instead of passing props?

The Core Difference

Props flow data explicitly from parent to immediate child. Context flows data implicitly from any ancestor to any descendant.

Loading diagram...
Notice how with props, every intermediate component must handle the data (yellow). With context, intermediates are completely unaware (gray).

When Props Excel

Props win for direct parent-child communication where explicitness matters:

<!-- ProductCard.svelte -->
<script lang="ts">
	import ProductImage from './ProductImage.svelte'
	import ProductTitle from './ProductTitle.svelte'
	import ProductPrice from './ProductPrice.svelte'

	interface Props {
		product: Product
	}

	let { product }: Props = $props()
</script>

<article class="product-card">
	<ProductImage src={product.image} alt={product.name} />
	<ProductTitle title={product.name} />
	<ProductPrice amount={product.price} currency="USD" />
</article>

Each child declares exactly what it needs. The contract is explicit, typed, and documented by the code itself. You can read ProductImage.svelte and immediately understand its data requirements without hunting through ancestor components.

Props strengths:

  • Explicit — You see exactly what data a component needs by reading its props
  • Type-safe — Full TypeScript support with IDE autocomplete and error checking
  • Discoverable — Props appear in component signatures, making APIs self-documenting
  • Testable — Easy to provide different props in tests without mocking context
  • Refactorable — Rename a prop and TypeScript shows you every usage

When Context Wins

Context wins when data must skip multiple levels, keeping intermediate components clean:

<!-- The Prop Drilling Problem -->
<App>
	<!-- has user -->
	<Layout {user}>
		<!-- passes through -->
		<Sidebar {user}>
			<!-- passes through -->
			<NavMenu {user}>
				<!-- passes through -->
				<UserAvatar {user} />
				<!-- finally uses it -->
			</NavMenu>
		</Sidebar>
	</Layout>
</App>
<!-- The Context Solution -->
<App>
	<!-- setContext('user', user) -->
	<Layout>
		<!-- clean! -->
		<Sidebar>
			<!-- clean! -->
			<NavMenu>
				<!-- clean! -->
				<UserAvatar />
				<!-- getContext('user') -->
			</NavMenu>
		</Sidebar>
	</Layout>
</App>

The context version eliminates four intermediate props. Layout, Sidebar, and NavMenu remain decoupled from user data they don’t use.

Context strengths:

  • No drilling — Intermediate components stay clean and focused
  • Decoupled — Components only declare what they actually use
  • Scoped — Different subtrees can have different values via context shadowing
  • Flexible — Add new context consumers without modifying intermediates

Context weaknesses:

  • Implicit — Must read code to know what context a component needs
  • Less discoverable — Dependencies not in component signature
  • Testing overhead — Need provider wrappers in tests

Trade-off Summary

AspectPropsContext
Dependencies✔ Explicit (self-documenting)✘ Implicit (hidden requirements)
TypeScript✔ Better inference⚠️ Good but less discoverable
Data flow tracing✔ Easier to trace⚠️ Requires checking providers
Reusability✔ No context dependency✘ Coupled to context structure
Intermediate components✘ Must pass through all✔ Skips intermediates
Coupling✘ Intermediates coupled to data✔ Reduces coupling
Ambient data⚠️ Verbose for theme/auth/locale✔ Better fit

Best Fit Guide:

Props TerritoryContext Territory
Direct parent-child3+ levels deep
1-2 levels deepCompound components
Component library public APITheme/Auth/Locale ambient data

Side-by-Side Example: Theme Data

Let’s see both approaches solving the same problem:

Props approach:

<!-- App.svelte -->
<script>
	let theme = $state({ mode: 'light', primary: '#3b82f6' })
</script>

<Layout {theme}>
	<slot />
</Layout>

<!-- Layout.svelte -->
<script>
	let { theme, children } = $props()
</script>

<Header {theme} />
<main>{@render children()}</main>
<Footer {theme} />

<!-- Header.svelte -->
<script>
	let { theme } = $props()
</script>

<nav style:background={theme.mode === 'dark' ? '#1f2937' : '#fff'}>
	<Logo {theme} />
	<Navigation {theme} />
	<ThemeToggle {theme} />
</nav>

Context approach:

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

	let theme = $state({ mode: 'light', primary: '#3b82f6' })

	setContext('theme', {
		get mode() {
			return theme.mode
		},
		get primary() {
			return theme.primary
		},
		toggle() {
			theme.mode = theme.mode === 'light' ? 'dark' : 'light'
		}
	})
</script>

<Layout>
	<slot />
</Layout>

<!-- Layout.svelte -->
<script>
	let { children } = $props()
</script>

<Header />
<main>{@render children()}</main>
<Footer />

<!-- Header.svelte -->
<script>
	import { getContext } from 'svelte'

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

<nav style:background={theme.mode === 'dark' ? '#1f2937' : '#fff'}>
	<Logo />
	<Navigation />
	<ThemeToggle />
</nav>

The context approach results in cleaner intermediate components (Layout no longer mentions theme), while the props approach makes theme dependencies visible in each component’s interface.

The Hybrid Approach

Production applications often combine both patterns strategically:

<!-- ProductCard.svelte -->
<script>
	import { getContext } from 'svelte'

	// Props for this component's direct concerns
	let { product, variant = 'default' } = $props()

	// Context for cross-cutting concerns
	const cart = getContext('cart')
	const theme = getContext('theme')
</script>

<article class="card {variant}" style="--bg: {theme.colors.surface}">
	<h3>{product.name}</h3>
	<p>${product.price}</p>
	<button onclick={() => cart.addItem(product)}>Add to Cart</button>
</article>

Product data (specific to this card) comes via props. Cross-cutting concerns (cart, theme) come via context. This separation keeps the component’s primary API explicit while avoiding prop drilling for ambient data.

Decision Guidance: Props vs Context

ScenarioBest ChoiceReasoning
Direct parent-childPropsClear, explicit, fully typed
1-2 levels deepPropsDrilling overhead is manageable
3+ levels deepContextAvoid coupling intermediate components
Component library public APIPropsConsumers need explicit, documented interface
Internal compound component stateContextImplementation detail, not part of API
Different values per subtreeContextContext shadowing enables this naturally
Maximum type safety requiredPropsBetter IDE support and refactoring
Theme, auth, localeContextAmbient data used throughout subtree

Context vs Stores

Stores ($state at module level or Svelte’s reactive stores) provide another approach to shared state. The comparison here is more nuanced because stores and context solve overlapping but distinct problems.

The Core Difference

Context is tree-scoped: it flows down the component hierarchy and can have different values in different subtrees. Stores are import-scoped: any module that imports the store sees the same value.

Loading diagram...

With context, Subtree B can shadow the theme with ‘dark’, and all its descendants see the dark theme while Subtree A continues to see ‘light’. With stores, everyone sees the same value—there’s no way to have different themes in different parts of the app.

When Stores Excel

Stores shine for truly global state that’s identical everywhere:

// src/lib/stores/notifications.svelte.ts
interface Notification {
	id: string
	message: string
	type: 'info' | 'success' | 'error'
}

let notifications = $state<Notification[]>([])

export function addNotification(message: string, type: Notification['type'] = 'info') {
	const id = crypto.randomUUID()
	notifications.push({ id, message, type })

	setTimeout(() => removeNotification(id), 5000)
}

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

export function getNotifications() {
	return notifications
}

Any component anywhere can trigger a notification:

<!-- DeepNestedComponent.svelte -->
<script>
	import { addNotification } from '$lib/stores/notifications.svelte.js'

	function handleSave() {
		// ... save logic
		addNotification('Saved successfully!', 'success')
	}
</script>

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

And a single UI component renders them all:

<!-- NotificationToast.svelte -->
<script>
	import { getNotifications, removeNotification } from '$lib/stores/notifications.svelte.js'

	const notifications = getNotifications()
</script>

<div class="toast-container">
	{#each notifications as notification (notification.id)}
		<div class="toast {notification.type}">
			{notification.message}
			<button onclick={() => removeNotification(notification.id)}>×</button>
		</div>
	{/each}
</div>

Context would be awkward here—the notification trigger and display might be in completely different parts of the tree with no common ancestor.

Store strengths:

  • Truly global — Same instance everywhere, no providers needed
  • Works anywhere — Components, utilities, API clients all have access
  • Persists — Data survives component unmounts and navigation
  • Simple — Just import and use, no ceremony

When Context Wins Over Stores

Context wins when different parts of the app need different values:

<!-- Different cart contexts for different vendors -->
<MarketplacePage>
	<!-- Vendor A's section with its own cart -->
	<CartProvider storageKey="vendor-a-cart" taxRate={0.08}>
		<VendorAProducts />
		<VendorACart />
	</CartProvider>

	<!-- Vendor B's section with its own cart -->
	<CartProvider storageKey="vendor-b-cart" taxRate={0.1}>
		<VendorBProducts />
		<VendorBCart />
	</CartProvider>
</MarketplacePage>

A store-based cart would force all vendors to share one cart. Context allows complete isolation by subtree.

Context strengths:

  • SSR-safe — Each request gets fresh context automatically
  • Subtree scoping — Different providers can coexist with different values
  • Clear ownership — Provider component owns the state lifecycle
  • Testable — Wrap with mock provider for testing

The SSR Problem with Stores

Critical: SSR Store Danger

Module-level stores are dangerous in SSR environments. In SvelteKit, server-side rendering shares module scope across all requests. This means a store defined at module level is shared between all users, leading to data leaks and security vulnerabilities.

// ❌ DANGEROUS: Shared across all users on server
// src/lib/stores/user.svelte.ts
let currentUser = $state<User | null>(null)

export function setUser(user: User) {
	currentUser = user // User A sets this...
}

export function getUser() {
	return currentUser // ...User B sees User A's data!
}

Request A sets currentUser to Alice. Before the response completes, request B arrives and reads currentUser — getting Alice’s data instead of Bob’s.

Here’s a visual representation of the SSR danger:

Loading diagram...

Safe Store Patterns for SSR

If you need stores with SSR, there are safe patterns to follow. You can combine context and factory functions to create per-request store instances. And because Context is bound to component instances, which are created fresh for each request there’s no risk of cross-request data leakage.

// Option 1: Factory function + context (best of both worlds)
// src/lib/user/context.ts
import { getContext, setContext } from 'svelte'

const USER_KEY = Symbol('user')

export function createUserStore() {
	let user = $state<User | null>(null)

	return {
		get user() {
			return user
		},
		setUser(u: User) {
			user = u
		},
		clear() {
			user = null
		}
	}
}

export function setUserContext(store: ReturnType<typeof createUserStore>) {
	setContext(USER_KEY, store)
}

export function getUserContext() {
	return getContext<ReturnType<typeof createUserStore>>(USER_KEY)
}
<!-- +layout.svelte -->
<script>
	import { createUserStore, setUserContext } from '$lib/user/context.js'

	let { data, children } = $props()

	// Fresh store per request (SSR-safe)
	const userStore = createUserStore()
	userStore.setUser(data.user)
	setUserContext(userStore)
</script>

{@render children()}

This gives you store-like ergonomics with context’s SSR safety.

Context is SSR-safe

Remember: Context is inherently SSR-safe because each request gets its own component tree and thus its own context instances. This makes context the go-to choice for per-request state in SSR applications.

Combining Stores and Context

For SSR apps that need persistent client-side state:

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

	let { children } = $props()

	// SSR-safe initialization
	let items = $state<CartItem[]>([])

	// Hydrate from localStorage on client only
	$effect(() => {
		if (browser) {
			const saved = localStorage.getItem('cart')
			if (saved) {
				const parsed = JSON.parse(saved)
				items.length = 0
				items.push(...parsed)
			}
		}
	})

	// Persist changes on client only
	$effect(() => {
		if (browser && items.length > 0) {
			localStorage.setItem('cart', JSON.stringify(items))
		}
	})

	setContext('cart', {
		get items() {
			return items
		},
		get total() {
			return items.reduce((sum, i) => sum + i.price * i.quantity, 0)
		},
		addItem(product: Product, quantity = 1) {
			const existing = items.find((i) => i.productId === product.id)
			if (existing) {
				existing.quantity += quantity
			} else {
				items.push({
					productId: product.id,
					name: product.name,
					price: product.price,
					quantity
				})
			}
		},
		clear() {
			items.length = 0
			if (browser) {
				localStorage.removeItem('cart')
			}
		}
	})
</script>

{@render children()}

This gives you SSR safety with client-side persistence—the best of both worlds.

Decision Guidance: Context vs Stores

ScenarioBest ChoiceReasoning
SSR applicationContextRequest isolation is automatic
Client-only stateEitherNo SSR concerns to worry about
Different values per subtreeContextContext shadowing enables this
Same value everywhereStoreSimpler, no providers needed
Non-component access neededStoreWorks in utilities, API clients
Data survives navigationStorePersists across component unmounts
Testable componentsContextMock via providers easily
External libraries need accessStoreCan import directly without context

Context vs Module State

Module-level state is the simplest form of shared state: a variable exported from a module. Understanding the difference between this and context is crucial for avoiding production bugs.

The Core Difference

Module state is global by definition—every importer shares the same variable. Context creates isolated instances per provider.

// Module state: one value, period
// counter.svelte.ts
let count = $state(0)

export function getCount() {
	return count
}
export function increment() {
	count++
}

// Context: one value per provider
// In ComponentA: setContext('counter', createCounter())
// In ComponentB: setContext('counter', createCounter())
// ComponentA's children get a different counter than ComponentB's

When Module State Makes Sense

Module state works for truly singleton concerns that:

  • Are identical for all users
  • Don’t need SSR isolation
  • Never vary by location in the tree
// src/lib/config.ts
// Configuration loaded once at startup, never changes per-user
export const features = {
	enableNewCheckout: import.meta.env.VITE_ENABLE_NEW_CHECKOUT === 'true',
	showBetaBanner: import.meta.env.VITE_SHOW_BETA_BANNER === 'true',
	maxUploadSize: 10 * 1024 * 1024
}
// src/lib/analytics.ts
// Side-effect system, doesn't hold user state
export function trackEvent(name: string, properties?: Record<string, unknown>) {
	fetch('/api/analytics', {
		method: 'POST',
		body: JSON.stringify({ name, properties, timestamp: Date.now() })
	})
}

export function trackPageView(path: string) {
	trackEvent('page_view', { path })
}

These are genuinely singleton—the same for every user, every request.

Why Module State Fails for User Data

Production Bug Alert

This pattern destroys your app in production:

// ❌ NEVER DO THIS
// src/lib/stores/auth.ts
let currentUser = $state<User | null>(null)

export function login(user: User) {
	currentUser = user
}

export function logout() {
	currentUser = null
}

export function getCurrentUser() {
	return currentUser
}

On the server:

  1. Request A logs in as Alice, sets currentUser = alice
  2. Request B arrives, calls getCurrentUser(), gets… Alice
  3. Request B renders Alice’s data for a completely different user

This isn’t a theoretical concern—it’s a production incident waiting to happen.

The Context Alternative

// src/lib/auth/context.ts
import { getContext, setContext } from 'svelte'

const AUTH_KEY = Symbol('auth')

export function createAuthContext() {
	let user = $state<User | null>(null)
	let loading = $state(true)

	return {
		get user() {
			return user
		},
		get loading() {
			return loading
		},
		get isAuthenticated() {
			return user !== null
		},

		setUser(u: User | null) {
			user = u
			loading = false
		},
		logout() {
			user = null
		}
	}
}

export function setAuthContext(ctx: ReturnType<typeof createAuthContext>) {
	setContext(AUTH_KEY, ctx)
}

export function getAuthContext() {
	return getContext<ReturnType<typeof createAuthContext>>(AUTH_KEY)
}

Each request creates its own auth context. No cross-contamination possible.

Decision Guidance: Context vs Module State

ScenarioBest ChoiceReasoning
Data is truly singleton (same for all)Module stateSimple and appropriate
Static configuration or constantsModule stateNever changes, no isolation needed
Client-only applicationsEitherNo SSR concerns
Side-effect systems (analytics, logging)Module stateNo user state involved
Data varies by user or requestContextIsolation is essential
Different parts of app need different valuesContextModule state can’t provide this
SSR applications with user dataContextSSR safety is non-negotiable
Testing requires isolationContextEasy to provide mock context

Context vs Events and Bindable Props

Events and bindable props handle different communication patterns than context. Understanding when to use each completes your component communication toolkit.

Events: Child-to-Parent Communication

In Svelte 5, events are implemented as callback props:

<!-- Child.svelte -->
<script lang="ts">
	interface Props {
		onselect?: (item: Item) => void
	}

	let { onselect }: Props = $props()

	function handleClick(item: Item) {
		onselect?.(item)
	}
</script>

<button onclick={() => handleClick({ id: 1, name: 'Item' })}>
	Select
</button>

<!-- Parent.svelte -->
<script>
	import Child from './Child.svelte'

	function handleSelect(item) {
		console.log('Selected:', item)
	}
</script>

<Child onselect={handleSelect} />

Context for Deep Upward Communication

For communication that needs to skip levels going upward, put callbacks in context:

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

	let { onsubmit, children } = $props()

	let fields = $state<Record<string, unknown>>({})
	let errors = $state<Record<string, string>>({})

	function validate() {
		// validation logic
		return Object.keys(errors).length === 0
	}

	setContext('form', {
		get fields() {
			return fields
		},
		get errors() {
			return errors
		},

		setField(name: string, value: unknown) {
			fields[name] = value
		},

		setError(name: string, error: string) {
			errors[name] = error
		},

		submit() {
			if (validate()) {
				onsubmit?.(fields)
			}
		}
	})
</script>

{@render children()}
<!-- DeepInput.svelte (many levels deep) -->
<script>
	import { getContext } from 'svelte'

	let { name, label } = $props()
	const form = getContext('form')
</script>

<label>
	{label}
	<input
		value={form.fields[name] ?? ''}
		oninput={(e) => form.setField(name, e.target.value)}
	/>
	{#if form.errors[name]}
		<span class="error">{form.errors[name]}</span>
	{/if}
</label>

<!-- DeepSubmitButton.svelte -->
<script>
	import { getContext } from 'svelte'

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

<button onclick={form.submit}>Submit</button>

The form provider exposes a submit method in context, which internally calls the onsubmit callback prop. This bridges context’s downward flow with the upward notification to the parent.

Bindable Props: Two-Way Binding

Bindable props ($bindable) enable two-way binding between parent and child:

<!-- ColorPicker.svelte -->
<script lang="ts">
	let { color = $bindable('#000000') } = $props()
</script>

<input type="color" bind:value={color} />

<!-- Parent.svelte -->
<script>
	import ColorPicker from './ColorPicker.svelte'

	let selectedColor = $state('#ff0000')
</script>

<ColorPicker bind:color={selectedColor} />
<p>Selected: {selectedColor}</p>

Changes in either direction propagate automatically.

Communication Pattern Summary

Communication Patterns (ASCII Art)

Props: Parent → Child
[Parent] --data--> [Child]

Events: Child → Parent
[Child] --callback--> [Parent]

Bindable: Bidirectional
[Parent] <==bind:value==> [Child]

Context Callback: Deep → Ancestor
[Ancestor: setContext with callback]
^
|
[Deep Descendant: calls context.submit()]
|
v
[Ancestor: callback triggered]

Decision Guidance: Communication Patterns

NeedBest Approach
Parent provides data to childProps
Child notifies parent of actionEvent (callback prop)
Simple two-way bindingBindable props
Deep descendant triggers ancestorContext with callback
Complex form stateContext (form pattern)
Simple parent-child form inputBindable props

Context vs SvelteKit Load Functions

SvelteKit’s load functions fetch data on the server. Context shares it with components. They’re complementary, not competing.

SvelteKit Load Basics

// +page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	const user = await getUser(locals.session)
	const posts = await getPosts(user.id)

	return { user, posts }
}
<!-- +page.svelte -->
<script>
	let { data } = $props()
	// data.user, data.posts available via props
</script>

<h1>Welcome, {data.user.name}</h1>

Load data arrives via the data prop. This is the standard pattern for route-level data.

Context for Deep Access

When deep descendants need load data without prop drilling:

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

	let { data, children } = $props()

	// Make user available to all descendants
	setContext('user', {
		get current() {
			return data.user
		},
		get permissions() {
			return data.user.permissions
		}
	})
</script>

{@render children()}
<!-- src/lib/components/UserBadge.svelte (used anywhere) -->
<script>
	import { getContext } from 'svelte'

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

<span class="badge">{user.current.name}</span>

Data Flow Architecture

Loading diagram...

Decision Guidance: Load vs Context

ConcernBest Approach
Fetching server dataSvelteKit load
Route-specific dataSvelteKit load → props
Sharing load data deeplyLoad → Context
Authentication statehooks.server.ts + load → Context
Client-side only stateContext or stores
Global UI state (notifications)Context

Why External Libraries Are Rarely Needed

Svelte 5’s built-in tools—$state, $derived, $effect, and context—handle virtually all state management needs elegantly. Unlike some other frameworks, Svelte’s reactivity system is comprehensive enough that external state management libraries add complexity without meaningful benefit for the vast majority of applications.

Svelte 5’s Complete Toolkit

State Management NeedSvelte 5 Solution
Component-scoped state$state
Computed values$derived
Side effects$effect
Tree-scoped shared stateContext API
Global shared stateModule-level $state in .svelte.ts files
SSR-safe shared stateContext with factory functions
Form stateContext (form pattern)
Theme/Auth/LocaleContext
Data fetchingSvelteKit load functions
Client-only globalsModule-level $state (with SSR awareness)

The Practical Pattern

Most applications need nothing more than this:

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

	let { children } = $props()

	let items = $state([])
	let loading = $state(false)
	let error = $state(null)

	setContext('data', {
		get items() {
			return items
		},
		get loading() {
			return loading
		},
		get error() {
			return error
		},
		async load() {
			loading = true
			error = null
			try {
				const response = await fetch('/api/items')
				items = await response.json()
			} catch (e) {
				error = e instanceof Error ? e : new Error(String(e))
			} finally {
				loading = false
			}
		}
	})
</script>

{@render children()}

This pattern provides loading states, error handling, and reactive data—all with zero external dependencies.

Adding Caching When Needed

If you need simple caching, extend the pattern:

// lib/context/users.svelte.ts
export class UsersContext {
	#users = $state<User[]>([])
	#loading = $state(false)
	#error = $state<Error | null>(null)
	#lastFetched = $state<number>(0)
	#cacheTime = 5 * 60 * 1000 // 5 minutes

	get users() {
		return this.#users
	}
	get isLoading() {
		return this.#loading
	}
	get error() {
		return this.#error
	}

	async load(forceRefresh = false) {
		// Return cached data if fresh
		const isCacheValid = Date.now() - this.#lastFetched < this.#cacheTime
		if (!forceRefresh && isCacheValid && this.#users.length > 0) {
			return
		}

		this.#loading = true
		this.#error = null

		try {
			const response = await fetch('/api/users')
			this.#users = await response.json()
			this.#lastFetched = Date.now()
		} catch (e) {
			this.#error = e instanceof Error ? e : new Error(String(e))
		} finally {
			this.#loading = false
		}
	}

	invalidate() {
		this.#lastFetched = 0
	}
}

This provides time-based caching, force refresh, and cache invalidation—patterns that cover most real-world needs without external dependencies.

The Bottom Line

Start with Svelte’s built-ins. They are designed to work together seamlessly and handle the state management needs of most applications elegantly. Svelte 5’s runes-based reactivity combined with the Context API is remarkably powerful.

The rare exceptions where external tools might help involve highly specialized requirements like complex finite state machines or real-time collaborative editing—scenarios most applications never encounter.

When in doubt, build with Svelte’s native patterns first. You’ll find they take you further than you might expect.


Hybrid Patterns: Combining Approaches

Real applications often combine multiple patterns strategically. Here’s how they work together.

Context Providing Store-like Access

Combine context’s isolation with store-like ergonomics:

// src/lib/cart/context.ts
import { getContext, setContext } from 'svelte'

const CART_KEY = Symbol('cart')

export function createCartStore() {
	let items = $state<CartItem[]>([])

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

	return {
		get items() {
			return items
		},
		get subtotal() {
			return subtotal
		},
		get itemCount() {
			return items.length
		},

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

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

		clear() {
			items.length = 0
		}
	}
}

export type CartStore = ReturnType<typeof createCartStore>

export function setCartContext(store: CartStore) {
	setContext(CART_KEY, store)
}

export function getCartContext(): CartStore {
	return getContext<CartStore>(CART_KEY)
}

This gives you:

  • Context isolation: Each provider creates its own cart (SSR-safe)
  • Store ergonomics: Simple import and use pattern
  • Type safety: Full TypeScript support
  • Testability: Easy to provide mock implementations

Props for Configuration, Context for Runtime

<!-- CartProvider.svelte -->
<script lang="ts">
	import { setCartContext, createCartStore } from './context.js'
	import { browser } from '$app/environment'

	interface Props {
		taxRate?: number
		currency?: string
		persistKey?: string
		children: import('svelte').Snippet
	}

	let { taxRate = 0.0, currency = 'USD', persistKey, children }: Props = $props()

	// Runtime state via context
	const cart = createCartStore()
	setCartContext(cart)

	// Persistence effect if key provided
	$effect(() => {
		if (persistKey && browser) {
			localStorage.setItem(persistKey, JSON.stringify(cart.items))
		}
	})
</script>

{@render children()}

Props configure the provider (static). Context provides the runtime state (dynamic).

Module Constants + Context State

// src/lib/config.ts
// Static configuration as module constants (safe)
export const API_BASE = import.meta.env.VITE_API_URL
export const APP_NAME = 'MyApp'
export const MAX_UPLOAD_SIZE = 10 * 1024 * 1024
// src/lib/user/context.ts
// Dynamic, per-request state as context (isolated)
import { API_BASE } from '$lib/config.js'

export function createUserContext(initialUser: User | null) {
	let user = $state(initialUser)

	return {
		get user() {
			return user
		},

		async refresh() {
			const response = await fetch(`${API_BASE}/me`)
			user = await response.json()
		}
	}
}

Module constants for static configuration. Context for dynamic, user-specific state. Each used where appropriate.


The Decision Framework

Use this flowchart to choose the right approach for any state management need:

Loading diagram...

Comparison Matrix

Quick reference for pattern selection:

FeaturePropsContextStoresEventsModule State
DirectionParent→ChildAncestor→DescendantAnywhereChild→ParentAnywhere
Explicit deps✅ Best❌ Implicit❌ Implicit✅ Best❌ Implicit
Type safety✅ Best⚠️ Good⚠️ Good✅ Best⚠️ Good
Prop drilling❌ Required✅ Avoided✅ Avoided❌ Required✅ Avoided
Subtree scoping❌ No✅ Yes❌ NoN/A❌ No
SSR safe✅ Always✅ Always⚠️ Careful✅ Always❌ Dangerous
Non-component access❌ No❌ No✅ Yes❌ No✅ Yes
Testability✅ Best⚠️ Good⚠️ Varies✅ Best❌ Hard
Survives unmountN/A❌ No✅ YesN/A✅ Yes
IDE support✅ Best⚠️ Good⚠️ Good✅ Best⚠️ Good

Real-World Scenarios

E-commerce Application

StatePatternReasoning
User authenticationLoad → ContextSSR-safe, shared deeply throughout app
Shopping cartContextSSR-safe, different carts per section possible
Product listLoad → PropsRoute-specific, explicit component API
ThemeContextSubtree theming possible
Toast notificationsContextUI component renders, triggers everywhere
Analytics configModule stateTruly global, no user data

Dashboard Application

StatePatternReasoning
Auth/permissionsLoad → ContextSSR-safe, check everywhere
Selected workspaceContextDifferent per route section
Widget dataLoad per widgetRoute-based, isolated
User preferencesContextSubtree application
Real-time updatesStore (client-only)WebSocket handler needs access

Form-Heavy Application

StatePatternReasoning
Form valuesContextMany inputs, centralized management
Validation errorsContextShared with all fields
Field-level state$stateComponent local
Submit handlerContext callbackCalled from deep submit button
Dirty tracking$derived in contextComputed from fields

Common Mistakes and Anti-Patterns

Mistake 1: Choosing by familiarity, not fit

Don’t use stores everywhere because you’re comfortable with them. Don’t use context for everything because you read this series. Match the pattern to the problem.

Mistake 2: Module state with user data

// ❌ This will cause cross-request data leaks in SSR
let currentUser = $state(null)

If data varies by user, it cannot be module-level in SSR apps.

Mistake 3: Context for direct parent-child

<!-- ❌ Over-engineered -->
<Parent>
	<!-- setContext -->
	<Child />
	<!-- getContext -->
</Parent>

Props are simpler, clearer, and better typed for direct relationships.

Mistake 4: Stores for subtree-scoped data

// ❌ Can't have different themes in different sections
// src/lib/theme.ts
let theme = $state('light')

Module-level state is global. If different parts of the app need different values, use context.

Mistake 5: Props through 5+ levels

<!-- ❌ Prop drilling pain -->
<A {data}>
	<B {data}>
		<C {data}>
			<D {data}>
				<E {data} />
			</D>
		</C>
	</B>
</A>

Context eliminates this coupling. Don’t torture your intermediate components.

Mistake 6: Ignoring SSR concerns

// ❌ Works in dev, breaks in production
export let globalState = $state({ user: null })

Always consider SSR safety when choosing patterns for shared state.


Pattern Selection Quick Reference

Use this matrix for quick decisions:

ScenarioRecommended Pattern
Direct parent → child dataProps
Skip 2+ levelsContext
Ambient data (theme, auth, locale)Context
Compound components (Tabs/Tab)Context
Different values in different subtreesContext
Truly global singleton (same for all users)Module state
Client-only global stateStore (with caution)
SSR app with user dataContext
Sibling communicationLift state + props
Cross-tree communication (notifications)Store (client-only) or Context (SSR)
Static configurationModule constants
Child notifies parentEvents (callback props)
Two-way parent-child bindingBindable props
Server-fetched route dataSvelteKit Load
Deep access to load dataLoad → Context

Conclusion

The question isn’t which pattern is best—it’s which pattern fits each situation. Each state management approach in Svelte 5 excels in specific scenarios:

Props provide explicit, type-safe parent-child communication. They’re the foundation of component APIs and should be your default for direct relationships.

Context shines when you need to skip intermediate components or scope different values to different subtrees. It’s SSR-safe by design and perfect for ambient data like themes, authentication, and configuration.

Stores work for truly global state that non-component code needs to access. Use them carefully in SSR applications, preferring the context-wrapped factory pattern.

Events (callback props) communicate upward from children to parents. Combine them with context for deep-to-ancestor communication patterns.

Module state is appropriate only for genuine singletons that don’t vary by user or request. Never use it for user-specific data in SSR applications.

SvelteKit Load fetches route data on the server. Combine it with context to share that data deeply without prop drilling.

The most maintainable applications use multiple patterns in combination, choosing the right tool for each job. Start simple with $state and props, then reach for context when you hit prop drilling. Reserve stores for truly global client-side state, and always consider SSR safety in your architecture decisions.


Key Takeaways

Props — Best for direct parent-child, explicit APIs, maximum type safety. Default choice for 1-2 levels.

Context — Best for skipping levels, subtree scoping, SSR safety, and internal compound component state. Use when props drill through 3+ levels.

Stores — Best for truly global state, non-component access, and data that survives unmounts. Be careful with SSR.

Events — Best for child-to-parent notification. Combine with context callbacks for deep communication.

Module State — Only for genuine singletons (config, analytics). Never for user data in SSR apps.

SvelteKit Load — Best for server-fetched route data. Combine with context for deep access.

The best applications combine approaches: load data with SvelteKit, share it deeply with context, use props for explicit component APIs, events for child-to-parent actions, and reserve stores for truly global non-SSR state.


What’s Next

Put context into practice with Theme Management with Context, building a complete theme system with light/dark modes, system preference detection, and CSS custom properties.


See Also

Official Documentation