State Management Across Component Boundaries in Svelte 5

State management is the beating heart of any reactive application. It determines how data flows through your components, how your UI responds to changes, and ultimately how maintainable and performant your application becomes as it scales. Svelte 5 has fundamentally reimagined state management with the introduction of runes, providing developers with a more explicit, predictable, and powerful toolkit for managing reactive state.

In Svelte 4 and earlier versions, developers relied on a combination of reactive declarations ($:), stores from svelte/store, and the Context API to manage state across different scopes. While these tools were effective, they created a fragmented mental model. The reactive state inside components worked differently than reactive state outside components, and sharing state required understanding multiple paradigms.

Svelte 5’s runes unify this experience. The $state rune works identically whether you’re inside a component or inside a .svelte.js module. The $derived rune replaces both reactive declarations and derived stores. This consistency means you can focus on understanding one set of patterns that apply everywhere, rather than learning different approaches for different contexts.

However, this unified system introduces new questions: When should state live inside a single component? When should it be elevated to a shared module? When does the Context API become necessary? And when might you want to opt out of deep reactivity with $state.raw to build more performant custom stores?

This article will guide you through these decisions systematically. We’ll build a comprehensive mental model for state boundaries, explore the tradeoffs of each approach, and develop practical patterns you can apply immediately in your Svelte 5 applications. By the end, you’ll understand not just how to use each state management approach, but why and when each one becomes the optimal choice.

Understanding State Boundaries: A Mental Model

Before diving into specific APIs, let’s establish a mental framework for thinking about state boundaries. Every piece of state in your application exists somewhere on a spectrum from “completely local” to “globally shared.” Understanding where a particular piece of state belongs on this spectrum is the first step toward choosing the right management approach.

The State Scope Spectrum

Consider state as existing across four primary scopes, each with distinct characteristics:

Local Component State represents data that is intrinsically tied to a single component instance. Think of form input values during editing, UI state like whether a dropdown is open, or temporary calculations that only matter within that component’s lifecycle. This state is born when the component mounts and dies when it unmounts. It has no meaning outside its component.

Shared Subtree State encompasses data that needs to flow through a portion of your component tree without being explicitly passed through every intermediate component. Consider a theme object that an entire feature section needs access to, or user preferences that multiple deeply nested components require. This state has meaning beyond a single component but doesn’t need to be globally available.

Module-Level State refers to singleton data that persists for the lifetime of your application (or until the module is reloaded). Configuration objects, caches, or application-wide state that any component might need fall into this category. In a purely client-side application, this is effectively global state.

Request-Scoped State (particularly relevant for SvelteKit applications) is state that must be isolated per-request during server-side rendering. This is crucial in SSR contexts where a single server process handles multiple users—state that leaks between requests can expose one user’s data to another.

Understanding these scopes helps you make informed decisions about where state should live. Let’s explore each approach in detail.

Local Component State with $state

The most common and straightforward form of state management is local component state using the $state rune. This is state that lives entirely within a single component instance, is created when the component initializes, and is cleaned up when the component is destroyed.

Basic Local State

When state is purely local to a component, $state provides a simple, powerful mechanism for creating reactive values:

<script>
	// Simple reactive counter - state lives entirely within this component
	let count = $state(0)

	// Complex reactive object - Svelte creates a deep reactive proxy
	let user = $state({
		name: '',
		email: '',
		preferences: {
			theme: 'light',
			notifications: true
		}
	})

	function incrementCount() {
		count++
	}

	function updateTheme(newTheme) {
		// Deep reactivity means this update triggers UI updates
		user.preferences.theme = newTheme
	}
</script>

<div>
	<p>Count: {count}</p>
	<button onclick={incrementCount}>Increment</button>

	<input bind:value={user.name} placeholder="Name" />
	<input bind:value={user.email} placeholder="Email" />

	<select bind:value={user.preferences.theme}>
		<option value="light">Light</option>
		<option value="dark">Dark</option>
	</select>
</div>

This example demonstrates two key characteristics of $state. First, primitives like numbers become reactive—any assignment triggers UI updates. Second, objects and arrays become deeply reactive proxies, meaning changes at any nesting level automatically propagate to the UI.

When Local State Is the Right Choice

Local state is appropriate when all of the following conditions are met:

The state is only meaningful within the context of a single component. A form’s validation errors, for instance, have no meaning outside the form component that generates them. The state’s lifecycle is tied to the component’s lifecycle—when the component is destroyed, the state should cease to exist. Additionally, no other component needs to read or modify this state directly.

Consider this more complete example of a search input component with local state:

<script>
	// All of this state is purely local - no other component cares about it
	let query = $state('')
	let isFocused = $state(false)
	let suggestions = $state([])
	let selectedIndex = $state(-1)
	let isLoading = $state(false)

	// Derived state - computed from local state
	let hasSuggestions = $derived(suggestions.length > 0)
	let selectedSuggestion = $derived(selectedIndex >= 0 ? suggestions[selectedIndex] : null)

	async function fetchSuggestions(searchQuery) {
		if (searchQuery.length < 2) {
			suggestions = []
			return
		}

		isLoading = true
		try {
			const response = await fetch(`/api/suggestions?q=${encodeURIComponent(searchQuery)}`)
			suggestions = await response.json()
			selectedIndex = -1
		} finally {
			isLoading = false
		}
	}

	function handleKeydown(event) {
		if (!hasSuggestions) return

		switch (event.key) {
			case 'ArrowDown':
				event.preventDefault()
				selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1)
				break
			case 'ArrowUp':
				event.preventDefault()
				selectedIndex = Math.max(selectedIndex - 1, -1)
				break
			case 'Enter':
				if (selectedSuggestion) {
					event.preventDefault()
					query = selectedSuggestion.text
					suggestions = []
				}
				break
			case 'Escape':
				suggestions = []
				selectedIndex = -1
				break
		}
	}

	// Effect to fetch suggestions when query changes
	$effect(() => {
		const currentQuery = query
		fetchSuggestions(currentQuery)
	})
</script>

<div class="search-container">
	<input
		type="text"
		bind:value={query}
		onfocus={() => (isFocused = true)}
		onblur={() => (isFocused = false)}
		onkeydown={handleKeydown}
		placeholder="Search..."
		aria-expanded={hasSuggestions}
		aria-autocomplete="list"
	/>

	{#if isLoading}
		<span class="loading-indicator">Searching...</span>
	{/if}

	{#if hasSuggestions && isFocused}
		<ul class="suggestions" role="listbox">
			{#each suggestions as suggestion, index}
				<li
					role="option"
					aria-selected={index === selectedIndex}
					class:selected={index === selectedIndex}
					onclick={() => {
						query = suggestion.text
						suggestions = []
					}}
				>
					{suggestion.text}
				</li>
			{/each}
		</ul>
	{/if}
</div>

<style>
	.search-container {
		position: relative;
		width: 100%;
		max-width: 400px;
	}

	.suggestions {
		position: absolute;
		top: 100%;
		left: 0;
		right: 0;
		background: white;
		border: 1px solid #ccc;
		border-radius: 4px;
		margin-top: 4px;
		padding: 0;
		list-style: none;
		box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
	}

	.suggestions li {
		padding: 8px 12px;
		cursor: pointer;
	}

	.suggestions li:hover,
	.suggestions li.selected {
		background: #f0f0f0;
	}

	.loading-indicator {
		position: absolute;
		right: 10px;
		top: 50%;
		transform: translateY(-50%);
		font-size: 0.8em;
		color: #666;
	}
</style>

Every piece of state in this component is intrinsically local. The query, suggestions, selectedIndex, isFocused, and isLoading values have no meaning outside this component. Other components might want to know the final search result, but that would be communicated through events or props, not by sharing internal state.

The Mistake of Premature State Elevation

A common anti-pattern is elevating state to a higher scope “just in case” it might be needed elsewhere later. This leads to several problems: it increases coupling between components, makes refactoring more difficult, and can lead to performance issues as more components react to state changes they don’t actually care about.

Keep state as local as possible until you have a concrete reason to share it. If you later discover that multiple components need access to the same state, you can elevate it at that point. This approach follows the principle of least privilege—components should only have access to the state they actually need.

Sharing State Across Modules with .svelte.js Files

When state needs to be shared between components but doesn’t require the scoping benefits of context, module-level state in .svelte.js or .svelte.ts files provides an elegant solution. This approach creates singleton state that persists for the lifetime of your application.

Understanding .svelte.js Files

Svelte 5 introduces a special file extension—.svelte.js (or .svelte.ts for TypeScript)—that allows you to use runes outside of component files. This enables you to create reusable reactive logic and shared state modules.

// file: counter.svelte.js

// This creates module-level reactive state - it's a singleton
// shared by all components that import this module
export const counter = $state({
	count: 0
})

export function increment() {
	counter.count++
}

export function decrement() {
	counter.count--
}

export function reset() {
	counter.count = 0
}

Any component that imports from this module will share the same reactive state:

<!-- ComponentA.svelte -->
<script>
	import { counter, increment } from './counter.svelte.js'
</script>

<p>Count: {counter.count}</p>
<button onclick={increment}>Increment</button>
<!-- ComponentB.svelte -->
<script>
	import { counter, decrement } from './counter.svelte.js'
</script>

<p>Current value: {counter.count}</p>
<button onclick={decrement}>Decrement</button>

Both components see the same counter object, and changes made in one component immediately reflect in the other.

The Critical Export Constraint

There’s an important limitation to understand when working with module-level state: you cannot directly export a reassignable state variable. This limitation exists because of how the Svelte compiler transforms rune-based code.

When you write let count = $state(0), the compiler transforms this into something like:

// Conceptual transformation (simplified)
let count = $.state(0)

// When you read count:
$.get(count)

// When you write count:
$.set(count, newValue)

This transformation happens per-file. If you export count directly and another file imports it, that other file doesn’t know to use $.get() and $.set()—it just sees a raw signal object rather than the reactive value.

Here’s what doesn’t work:

// file: broken.svelte.js
// ❌ This will NOT work as expected
export let count = $state(0)

export function increment() {
	count++ // This works internally
}

When another file imports count, it gets the internal signal object, not the reactive number value. Accessing it would give you an object instead of a number.

There are two patterns that do work:

Pattern 1: Export an object with reactive properties

// file: counter.svelte.js
// ✅ This works - we're not reassigning counter, just its properties
export const counter = $state({
	count: 0
})

export function increment() {
	counter.count++ // Updating a property, not reassigning counter
}

Pattern 2: Export accessor functions

// file: counter.svelte.js
// ✅ This also works - state is private, accessed via functions
let count = $state(0)

export function getCount() {
	return count
}

export function setCount(value) {
	count = value
}

export function increment() {
	count++
}

The first pattern is generally more ergonomic for most use cases. The second pattern provides more encapsulation and is useful when you want to control exactly how state can be modified.

Building a Complete Shared State Module

Let’s build a more realistic example—a shopping cart that multiple components need to access:

// file: cart.svelte.js

// The cart state - a singleton shared across the application
export const cart = $state({
	items: [],
	isOpen: false
})

// Derived values - these are also reactive
export function getItemCount() {
	return cart.items.reduce((sum, item) => sum + item.quantity, 0)
}

export function getSubtotal() {
	return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}

export function getTax(rate = 0.08) {
	return getSubtotal() * rate
}

export function getTotal(taxRate = 0.08) {
	return getSubtotal() + getTax(taxRate)
}

// Actions to modify cart state
export function addItem(product, quantity = 1) {
	const existingItem = cart.items.find((item) => item.id === product.id)

	if (existingItem) {
		existingItem.quantity += quantity
	} else {
		cart.items.push({
			id: product.id,
			name: product.name,
			price: product.price,
			image: product.image,
			quantity
		})
	}
}

export function removeItem(productId) {
	const index = cart.items.findIndex((item) => item.id === productId)
	if (index !== -1) {
		cart.items.splice(index, 1)
	}
}

export function updateQuantity(productId, quantity) {
	const item = cart.items.find((item) => item.id === productId)
	if (item) {
		if (quantity <= 0) {
			removeItem(productId)
		} else {
			item.quantity = quantity
		}
	}
}

export function clearCart() {
	cart.items = []
}

export function toggleCart() {
	cart.isOpen = !cart.isOpen
}

export function openCart() {
	cart.isOpen = true
}

export function closeCart() {
	cart.isOpen = false
}

Now multiple components can interact with the same cart:

<!-- CartIcon.svelte - Shows cart icon with item count in header -->
<script>
	import { cart, getItemCount, toggleCart } from './cart.svelte.js'

	// Using $derived to create a reactive binding to the function result
	let itemCount = $derived(getItemCount())
</script>

<button class="cart-icon" onclick={toggleCart} aria-label="Shopping cart with {itemCount} items">
	<svg><!-- cart icon --></svg>
	{#if itemCount > 0}
		<span class="badge">{itemCount}</span>
	{/if}
</button>
<!-- ProductCard.svelte - Each product can add itself to cart -->
<script>
	import { addItem, openCart } from './cart.svelte.js'

	let { product } = $props()

	function handleAddToCart() {
		addItem(product)
		openCart()
	}
</script>

<article class="product-card">
	<img src={product.image} alt={product.name} />
	<h3>{product.name}</h3>
	<p class="price">${product.price.toFixed(2)}</p>
	<button onclick={handleAddToCart}>Add to Cart</button>
</article>
<!-- CartDrawer.svelte - The sliding cart panel -->
<script>
	import {
		cart,
		updateQuantity,
		removeItem,
		getSubtotal,
		getTax,
		getTotal,
		closeCart
	} from './cart.svelte.js'

	let subtotal = $derived(getSubtotal())
	let tax = $derived(getTax())
	let total = $derived(getTotal())
</script>

<aside class="cart-drawer" class:open={cart.isOpen}>
	<header>
		<h2>Your Cart</h2>
		<button onclick={closeCart} aria-label="Close cart">×</button>
	</header>

	{#if cart.items.length === 0}
		<p class="empty-message">Your cart is empty</p>
	{:else}
		<ul class="cart-items">
			{#each cart.items as item (item.id)}
				<li class="cart-item">
					<img src={item.image} alt={item.name} />
					<div class="item-details">
						<h4>{item.name}</h4>
						<p>${item.price.toFixed(2)} each</p>
					</div>
					<div class="quantity-controls">
						<button onclick={() => updateQuantity(item.id, item.quantity - 1)}></button>
						<span>{item.quantity}</span>
						<button onclick={() => updateQuantity(item.id, item.quantity + 1)}> + </button>
					</div>
					<button
						class="remove-btn"
						onclick={() => removeItem(item.id)}
						aria-label="Remove {item.name} from cart"
					>
						×
					</button>
				</li>
			{/each}
		</ul>

		<footer class="cart-summary">
			<div class="summary-line">
				<span>Subtotal</span>
				<span>${subtotal.toFixed(2)}</span>
			</div>
			<div class="summary-line">
				<span>Tax</span>
				<span>${tax.toFixed(2)}</span>
			</div>
			<div class="summary-line total">
				<span>Total</span>
				<span>${total.toFixed(2)}</span>
			</div>
			<button class="checkout-btn">Proceed to Checkout</button>
		</footer>
	{/if}
</aside>

This pattern works beautifully for client-side applications where you want true singleton state. All components share the exact same cart instance, and changes anywhere immediately propagate everywhere.

When Module-Level State Is Appropriate

Module-level state is ideal when the state represents a true application-wide singleton that doesn’t need request isolation. Examples include user authentication state in client-side apps, application configuration that doesn’t change per-request, caches for expensive computations, and feature flags or A/B test configurations.

However, module-level state has a critical limitation that we must address: it’s dangerous in server-side rendering contexts.

The Server-Side Rendering Trap

When your application uses server-side rendering (SSR), module-level state becomes a potential security vulnerability. Here’s why: on the server, a single Node.js process handles requests from multiple users. Module-level state is shared across all those requests.

Consider this dangerous pattern:

// file: user.svelte.js
// ⚠️ DANGEROUS in SSR contexts!
export const currentUser = $state({
	id: null,
	name: null,
	email: null,
	isAuthenticated: false
})

export function setUser(userData) {
	currentUser.id = userData.id
	currentUser.name = userData.name
	currentUser.email = userData.email
	currentUser.isAuthenticated = true
}

export function clearUser() {
	currentUser.id = null
	currentUser.name = null
	currentUser.email = null
	currentUser.isAuthenticated = false
}

If Alice logs in, the server might render her dashboard and set her user data in currentUser. If Bob’s request comes in before Alice’s response completes, Bob might see Alice’s user data! This is a severe security vulnerability.

This is where the Context API becomes essential.

The Context API: Request-Scoped and Subtree-Scoped State

Svelte’s Context API solves the SSR state isolation problem and provides a mechanism for sharing state within a component subtree without prop drilling. Context is scoped to the component tree—each component instance that sets context creates an isolated “bubble” that its descendants can access.

Understanding Context Fundamentals

Context works through two primary functions: setContext establishes a value that descendants can access, and getContext retrieves that value. The context is associated with the component instance that sets it, meaning different instances of the same component can have different context values.

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

	// Create reactive state that will be shared via context
	let theme = $state({
		mode: 'light',
		primaryColor: '#007bff',
		fontFamily: 'Inter, sans-serif'
	})

	// Make it available to descendants
	setContext('theme', theme)

	function toggleMode() {
		theme.mode = theme.mode === 'light' ? 'dark' : 'light'
	}
</script>

<div class="app" data-theme={theme.mode}>
	<button onclick={toggleMode}>
		Switch to {theme.mode === 'light' ? 'dark' : 'light'} mode
	</button>
	<Child />
</div>
<!-- Child.svelte -->
<script>
	import { getContext } from 'svelte'
	import GrandChild from './GrandChild.svelte'

	// Retrieve the theme from parent's context
	const theme = getContext('theme')
</script>

<!-- No need to pass theme as prop to GrandChild -->
<div class="child" style="font-family: {theme.fontFamily}">
	<p>Current mode: {theme.mode}</p>
	<GrandChild />
</div>
<!-- GrandChild.svelte -->
<script>
	import { getContext } from 'svelte'

	// Can access the same context from any depth
	const theme = getContext('theme')
</script>

<div class="grandchild" style="background: {theme.mode === 'dark' ? '#333' : '#fff'}">
	<p style="color: {theme.primaryColor}">Styled by context!</p>
</div>

Reactive Context with $state

When you pass a $state object through context, the reactivity is preserved. Changes to the state object in the parent automatically propagate to all consuming descendants. This is because $state creates a proxy object, and context simply passes a reference to that same proxy.

However, there’s an important caveat: if you reassign the entire state variable in the parent, you “break the link”—descendants will still have a reference to the old object.

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

	let user = $state({
		name: 'Alice',
		preferences: { theme: 'light' }
	})

	setContext('user', user)

	// ✅ This works - updating properties maintains the reference
	function updateTheme(newTheme) {
		user.preferences.theme = newTheme
	}

	// ❌ This breaks reactivity for context consumers!
	function resetUser() {
		user = { name: 'Guest', preferences: { theme: 'light' } }
		// Descendants still have reference to the OLD object
	}

	// ✅ Instead, update properties individually
	function resetUserCorrectly() {
		user.name = 'Guest'
		user.preferences.theme = 'light'
	}
</script>

Svelte will warn you if you reassign a context value, helping you avoid this common mistake.

Type-Safe Context with Helper Functions

For TypeScript projects (and for better developer experience in general), it’s best practice to wrap setContext and getContext in helper functions. This ensures type safety and eliminates the need to manually manage string or symbol keys throughout your application:

// file: contexts/user.ts
import { setContext, getContext } from 'svelte'

interface User {
	id: string
	name: string
	email: string
	role: 'admin' | 'user' | 'guest'
	preferences: {
		theme: 'light' | 'dark' | 'system'
		language: string
		notifications: boolean
	}
}

// Use a symbol to avoid key collisions
const USER_KEY = Symbol('USER')

export function setUserContext(user: User) {
	setContext(USER_KEY, user)
}

export function getUserContext() {
	return getContext<User>(USER_KEY)
}
<!-- App.svelte -->
<script lang="ts">
	import { setUserContext } from './contexts/user'

	let user = $state({
		id: 'user-123',
		name: 'Alice',
		email: 'alice@example.com',
		role: 'user' as const,
		preferences: {
			theme: 'light' as const,
			language: 'en',
			notifications: true
		}
	})

	setUserContext(user)
</script>
<!-- SomeDeepComponent.svelte -->
<script lang="ts">
	import { getUserContext } from './contexts/user'

	// Fully typed - TypeScript knows the shape of user
	const user = getUserContext()
</script>

<p>Welcome, {user.name}!</p><p>Theme: {user.preferences.theme}</p>

Context for Request Isolation in SvelteKit

In SvelteKit applications, context provides request isolation that module-level state cannot. Each request creates a new component tree, and context set in that tree is isolated from other requests.

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

	let { data, children } = $props()

	// Create request-scoped user state from server data
	let user = $state(data.user ? { ...data.user } : null)

	// Each request gets its own isolated user context
	setContext('user', {
		get current() {
			return user
		},
		set current(value) {
			user = value
		},
		logout() {
			user = null
		}
	})
</script>

{@render children()}
<!-- src/routes/dashboard/+page.svelte -->
<script>
	import { getContext } from 'svelte'

	const userContext = getContext('user')

	// Safe to access - this is isolated per request
	const user = userContext.current
</script>

{#if user}
	<h1>Welcome back, {user.name}!</h1>
	<button onclick={() => userContext.logout()}>Logout</button>
{:else}
	<p>Please log in</p>
{/if}

When to Use Context vs Module State

Use context when you need request isolation for SSR safety, when state should only be accessible to a subtree (not globally), when different parts of your app need different instances of the same “type” of state, or when state ownership should be tied to component lifecycle.

Use module-level state when you’re building a purely client-side application, when state is truly a singleton that all components should share, when state should persist across navigation and component destruction, or when you need to access state outside of component contexts (like in utility functions).

Building Reactive Stores with $state.raw

While $state creates deeply reactive proxies that automatically track changes at any nesting level, this power comes with a cost. Proxy creation and maintenance has overhead, and for large data structures that you don’t intend to mutate in place, this overhead is unnecessary.

The $state.raw rune provides an escape hatch: it creates reactive state without the deep proxy wrapping. The value is only reactive at the top level—reassigning the entire value triggers updates, but mutating properties does not.

Understanding $state.raw Behavior

Let’s examine the difference between $state and $state.raw:

// With $state - deep reactivity via proxies
let user = $state({
	name: 'Alice',
	settings: {
		theme: 'dark'
	}
})

// This mutation IS reactive - triggers UI updates
user.settings.theme = 'light'

// With $state.raw - shallow reactivity only
let config = $state.raw({
	apiUrl: 'https://api.example.com',
	features: {
		darkMode: true,
		analytics: false
	}
})

// This mutation is NOT reactive - no UI updates!
config.features.darkMode = false

// This reassignment IS reactive - triggers UI updates
config = {
	apiUrl: 'https://api.example.com',
	features: {
		darkMode: false,
		analytics: false
	}
}

The key insight is that $state.raw values must be replaced rather than mutated to trigger reactivity.

Performance Benefits of $state.raw

For large data structures, avoiding proxy creation can provide significant performance benefits. Consider a data table with thousands of rows:

// file: dataTable.svelte.js

// ❌ With $state - each row object becomes a proxy
// Proxy creation happens for the array AND every nested object
export const tableData = $state({
	rows: [], // Could be 10,000+ rows
	sortColumn: null,
	sortDirection: 'asc',
	filters: {}
})

// ✅ With $state.raw for the data, $state for UI state
export const tableRows = $state.raw([])
export const tableUI = $state({
	sortColumn: null,
	sortDirection: 'asc',
	selectedIds: new Set(),
	filters: {}
})

export function setRows(newRows) {
	// Complete replacement - triggers reactivity
	tableRows = newRows
}

export function sortRows(column) {
	const direction =
		tableUI.sortColumn === column && tableUI.sortDirection === 'asc' ? 'desc' : 'asc'

	// Update UI state (reactive via $state)
	tableUI.sortColumn = column
	tableUI.sortDirection = direction

	// Sort and replace the raw data
	const sorted = [...tableRows].sort((a, b) => {
		const aVal = a[column]
		const bVal = b[column]
		const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0
		return direction === 'asc' ? comparison : -comparison
	})

	tableRows = sorted // Replacement triggers reactivity
}

Building an Immutable Data Store Pattern

$state.raw is perfect for implementing immutable data patterns where you always create new objects/arrays rather than mutating existing ones:

// file: todoStore.svelte.js

// Raw state - we'll manage updates through immutable patterns
let todos = $state.raw([])

// Export a getter to access current state
export function getTodos() {
	return todos
}

// All modifications create new arrays/objects
export function addTodo(text) {
	const newTodo = {
		id: crypto.randomUUID(),
		text,
		completed: false,
		createdAt: new Date()
	}

	// Create new array with new item - triggers reactivity
	todos = [...todos, newTodo]
}

export function toggleTodo(id) {
	// Map creates a new array with updated item
	todos = todos.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
}

export function updateTodoText(id, newText) {
	todos = todos.map((todo) => (todo.id === id ? { ...todo, text: newText } : todo))
}

export function deleteTodo(id) {
	// Filter creates a new array without the deleted item
	todos = todos.filter((todo) => todo.id !== id)
}

export function clearCompleted() {
	todos = todos.filter((todo) => !todo.completed)
}

// Bulk operations
export function setTodos(newTodos) {
	todos = newTodos
}

export function reorderTodos(fromIndex, toIndex) {
	const newTodos = [...todos]
	const [moved] = newTodos.splice(fromIndex, 1)
	newTodos.splice(toIndex, 0, moved)
	todos = newTodos
}

Using this store in a component:

<script>
	import { getTodos, addTodo, toggleTodo, deleteTodo, clearCompleted } from './todoStore.svelte.js'

	let newTodoText = $state('')

	// Access todos via the getter - this creates a reactive dependency
	let todos = $derived(getTodos())
	let completedCount = $derived(todos.filter((t) => t.completed).length)
	let remainingCount = $derived(todos.length - completedCount)

	function handleSubmit(event) {
		event.preventDefault()
		if (newTodoText.trim()) {
			addTodo(newTodoText.trim())
			newTodoText = ''
		}
	}
</script>

<div class="todo-app">
	<form onsubmit={handleSubmit}>
		<input type="text" bind:value={newTodoText} placeholder="What needs to be done?" />
		<button type="submit">Add</button>
	</form>

	<ul class="todo-list">
		{#each todos as todo (todo.id)}
			<li class:completed={todo.completed}>
				<input type="checkbox" checked={todo.completed} onchange={() => toggleTodo(todo.id)} />
				<span>{todo.text}</span>
				<button onclick={() => deleteTodo(todo.id)}>×</button>
			</li>
		{/each}
	</ul>

	<footer>
		<span>{remainingCount} items remaining</span>
		{#if completedCount > 0}
			<button onclick={clearCompleted}>
				Clear completed ({completedCount})
			</button>
		{/if}
	</footer>
</div>

Combining $state.raw with Reactive Containers

An advanced pattern involves using $state.raw for large data while wrapping it in a reactive container:

// file: hybridStore.svelte.js

// The store state - $state for the container, $state.raw for heavy data
export const store = $state({
	// Metadata is reactive - small, frequently accessed
	isLoading: false,
	error: null,
	lastUpdated: null,

	// Large dataset uses raw - only reactive on replacement
	items: $state.raw([]),

	// Pagination state
	page: 1,
	pageSize: 50,
	totalItems: 0
})

// Derived values that work with both reactive and raw state
export function getCurrentPageItems() {
	const start = (store.page - 1) * store.pageSize
	const end = start + store.pageSize
	return store.items.slice(start, end)
}

export function getTotalPages() {
	return Math.ceil(store.totalItems / store.pageSize)
}

// Actions
export async function fetchItems(page = 1) {
	store.isLoading = true
	store.error = null

	try {
		const response = await fetch(`/api/items?page=${page}&pageSize=${store.pageSize}`)
		const data = await response.json()

		// Replace raw items - triggers reactivity
		store.items = data.items
		store.totalItems = data.total
		store.page = page
		store.lastUpdated = new Date()
	} catch (error) {
		store.error = error.message
	} finally {
		store.isLoading = false
	}
}

export function updateItem(id, updates) {
	// Immutable update of the raw array
	store.items = store.items.map((item) => (item.id === id ? { ...item, ...updates } : item))
	store.lastUpdated = new Date()
}

export function removeItem(id) {
	store.items = store.items.filter((item) => item.id !== id)
	store.totalItems--
	store.lastUpdated = new Date()
}

When to Use $state.raw vs Regular $state

Use $state.raw when you’re working with large arrays (hundreds or thousands of items), when data comes from external sources and won’t be mutated in place (API responses, database records), when you want to enforce immutable update patterns in your codebase, when you’re building performance-critical components like data grids or virtualized lists, or when state is read-only or primarily replaced wholesale.

Stick with regular $state when you need fine-grained reactivity on nested properties, when the data structure is small enough that proxy overhead is negligible, when mutation-based updates are more ergonomic for your use case, or when working with deeply nested structures that are frequently updated at various levels.

Building a Complete Custom Store with $state.raw

Let’s build a full-featured data store that demonstrates these patterns working together:

// file: asyncDataStore.svelte.js

/**
 * Creates an async data store with loading states, caching, and optimistic updates
 */
export function createAsyncStore(fetchFn, options = {}) {
	const {
		cacheTime = 5 * 60 * 1000, // 5 minutes
		staleTime = 30 * 1000, // 30 seconds
		retryCount = 3,
		retryDelay = 1000
	} = options

	// Store state - hybrid approach
	const state = $state({
		// Reactive metadata
		status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
		error: null,
		lastFetchedAt: null,

		// Raw data - replaced on updates
		data: $state.raw(null),

		// Request management
		currentRequestId: null
	})

	// Cache management
	let cache = new Map()

	function getCacheKey(params) {
		return JSON.stringify(params)
	}

	function isStale() {
		if (!state.lastFetchedAt) return true
		return Date.now() - state.lastFetchedAt > staleTime
	}

	function isCacheValid(cacheEntry) {
		if (!cacheEntry) return false
		return Date.now() - cacheEntry.timestamp < cacheTime
	}

	async function fetchWithRetry(params, retriesLeft) {
		try {
			return await fetchFn(params)
		} catch (error) {
			if (retriesLeft > 0) {
				await new Promise((resolve) => setTimeout(resolve, retryDelay))
				return fetchWithRetry(params, retriesLeft - 1)
			}
			// TODO: throw? Or return a special error object?
			throw error
		}
	}

	// Public API
	return {
		// Reactive getters
		get status() {
			return state.status
		},
		get error() {
			return state.error
		},
		get data() {
			return state.data
		},
		get isLoading() {
			return state.status === 'loading'
		},
		get isSuccess() {
			return state.status === 'success'
		},
		get isError() {
			return state.status === 'error'
		},
		get isStale() {
			return isStale()
		},

		// Fetch data with caching
		async fetch(params = {}) {
			const cacheKey = getCacheKey(params)
			const requestId = Symbol()
			state.currentRequestId = requestId

			// Check cache first
			const cached = cache.get(cacheKey)
			if (isCacheValid(cached)) {
				state.data = cached.data
				state.status = 'success'
				state.lastFetchedAt = cached.timestamp

				// Still refetch in background if stale
				if (isStale()) {
					this.refetch(params)
				}
				return cached.data
			}

			state.status = 'loading'
			state.error = null

			try {
				const data = await fetchWithRetry(params, retryCount)

				// Only update if this is still the current request
				if (state.currentRequestId === requestId) {
					const now = Date.now()
					state.data = data
					state.status = 'success'
					state.lastFetchedAt = now

					// Update cache
					cache.set(cacheKey, { data, timestamp: now })
				}

				return data
			} catch (error) {
				if (state.currentRequestId === requestId) {
					state.error = error.message
					state.status = 'error'
				}
				// TODO: throw? Or return a special error object?
				throw error
			}
		},

		// Force refetch (bypass cache)
		async refetch(params = {}) {
			const cacheKey = getCacheKey(params)
			cache.delete(cacheKey)
			return this.fetch(params)
		},

		// Optimistic update with rollback
		async optimisticUpdate(updateFn, commitFn) {
			const previousData = state.data

			// Apply optimistic update
			state.data = updateFn(state.data)

			try {
				// Commit to server
				const result = await commitFn(state.data)

				// Update with server response if provided
				if (result !== undefined) {
					state.data = result
				}

				// Update cache
				const cacheKey = getCacheKey({})
				cache.set(cacheKey, { data: state.data, timestamp: Date.now() })

				return result
			} catch (error) {
				// Rollback on error
				state.data = previousData
				// TODO: throw? Or return a special error object?
				throw error
			}
		},

		// Manual update
		setData(newData) {
			state.data = newData
			state.lastFetchedAt = Date.now()

			const cacheKey = getCacheKey({})
			cache.set(cacheKey, { data: newData, timestamp: Date.now() })
		},

		// Clear all state
		reset() {
			state.status = 'idle'
			state.error = null
			state.data = null
			state.lastFetchedAt = null
			cache.clear()
		},

		// Invalidate cache
		invalidate() {
			cache.clear()
		}
	}
}

Using this store:

// file: userStore.svelte.js
import { createAsyncStore } from './asyncDataStore.svelte.ts'

async function fetchUser(params) {
	const response = await fetch(`/api/users/${params.id}`)
	if (!response.ok) error('Failed to fetch user')
	return response.json()
}

export const userStore = createAsyncStore(fetchUser, {
	cacheTime: 10 * 60 * 1000, // 10 minutes
	staleTime: 60 * 1000 // 1 minute
})
<!-- UserProfile.svelte -->
<script>
	import { userStore } from './userStore.svelte.js'

	let { userId } = $props()

	// Fetch user when component mounts or userId changes
	$effect(() => {
		userStore.fetch({ id: userId })
	})

	async function handleUpdateName(newName) {
		try {
			await userStore.optimisticUpdate(
				// Optimistic update function
				(user) => ({ ...user, name: newName }),
				// Commit function
				async (updatedUser) => {
					const response = await fetch(`/api/users/${userId}`, {
						method: 'PATCH',
						headers: { 'Content-Type': 'application/json' },
						body: JSON.stringify({ name: newName })
					})
					return response.json()
				}
			)
		} catch (error) {
			alert('Failed to update name. Changes have been reverted.')
		}
	}
</script>

<div class="user-profile">
	{#if userStore.isLoading && !userStore.data}
		<div class="skeleton">Loading...</div>
	{:else if userStore.isError}
		<div class="error">
			<p>Failed to load user: {userStore.error}</p>
			<button onclick={() => userStore.refetch({ id: userId })}> Retry </button>
		</div>
	{:else if userStore.data}
		<div class="profile-card">
			<h1>{userStore.data.name}</h1>
			<p>{userStore.data.email}</p>

			<button
				onclick={() => {
					const newName = prompt('Enter new name:', userStore.data.name)
					if (newName) handleUpdateName(newName)
				}}
			>
				Edit Name
			</button>

			{#if userStore.isStale}
				<span class="stale-indicator">
					Data may be outdated
					<button onclick={() => userStore.refetch({ id: userId })}> Refresh </button>
				</span>
			{/if}
		</div>
	{/if}
</div>

Best Practices and Common Pitfalls

Best Practices

Start local, elevate when needed. Begin with local $state and only move to shared state when you have a concrete need. Premature abstraction leads to unnecessary complexity.

Use context for subtree scope. When state needs to flow through a component subtree without prop drilling, context is the right choice. It provides clean isolation and SSR safety.

Choose module state for true singletons. Application-wide configuration, caches, and client-side auth state work well as module-level state—but only in client-side contexts.

Prefer object exports over primitive exports. Due to the module compilation constraint, always export objects or use getter functions when sharing state across modules.

Use $state.raw for large, immutable data. Large arrays, API responses, and data that follows immutable patterns benefit from avoiding proxy overhead.

Combine patterns thoughtfully. A hybrid approach often works best—$state.raw for data, regular $state for UI state, context for scoped sharing.

Common Pitfalls

Exporting reassignable state directly:

// ❌ Broken - other files can't use this reactively
export let count = $state(0)

// ✅ Fixed - export an object
export const counter = $state({ count: 0 })

Reassigning context values:

<script>
	let user = $state({ name: 'Alice' })
	setContext('user', user)

	// ❌ This breaks the context link!
	function resetUser() {
		user = { name: 'Guest' }
	}

	// ✅ Update properties instead
	function resetUser() {
		user.name = 'Guest'
	}
</script>

Using module state in SSR contexts:

// file: auth.svelte.js
// ❌ Dangerous in SSR - shared across requests!
export const currentUser = $state({ ... });

// ✅ Use context in layout instead
// See earlier section on request-scoped context

Mutating $state.raw values:

let items = $state.raw([])

// ❌ This mutation won't trigger updates
items.push({ id: 1 })

// ✅ Replace the entire value
items = [...items, { id: 1 }]

Forgetting that destructured values aren’t reactive:

let user = $state({ name: 'Alice' })
let { name } = user

// Later...
user.name = 'Bob'
console.log(name) // Still 'Alice'!

Performance Considerations

Deep reactivity has costs. Every nested object in $state becomes a proxy. For large data structures, this adds memory overhead and slight access latency.

$state.raw requires complete replacement. While it avoids proxy costs, creating new objects for every update has its own costs. For very frequent updates, measure both approaches.

Context lookups traverse the tree. While fast, getContext does perform a tree lookup. For performance-critical inner loops, consider caching the context reference.

Derived values are lazy. $derived only recomputes when accessed and its dependencies have changed. This is usually optimal, but be aware of it for debugging.

Conclusion

State management in Svelte 5 represents a significant evolution from previous versions. The runes system provides a unified, consistent approach to reactivity that works the same way whether you’re in a component or a module file. Understanding when to use each state management approach is key to building maintainable, performant applications.

Local $state should be your default for component-specific data. Module-level state in .svelte.js files provides convenient singleton state for client-side applications. The Context API offers request isolation for SSR safety and subtree-scoped sharing. And $state.raw gives you an escape hatch for performance-critical scenarios where deep reactivity isn’t needed.

The patterns we’ve explored—from simple counters to full-featured async stores—demonstrate that Svelte 5’s state management primitives are powerful enough to handle sophisticated use cases while remaining simple enough for everyday tasks. By understanding the tradeoffs of each approach and following the best practices we’ve outlined, you’ll be equipped to make informed decisions about state architecture in your Svelte applications.

As you apply these patterns, remember that state management decisions aren’t permanent. Start simple, measure performance when needed, and refactor as your understanding of your application’s needs deepens. The flexibility of Svelte 5’s system means you can adapt your approach as requirements evolve.