The Frozen Counter

You’ve learned the mechanics of context: how setContext broadcasts data and getContext receives it. You’ve seen how context flows through component trees and how the nearest ancestor wins. Everything makes sense until you try to build something real.

You create a counter in your provider, share it via context, and wire up a button to increment it. The button works—you can see the count increasing in the provider. But the consumer component? It’s frozen. It still shows the original value. Click after click, nothing changes. What’s going on?

This is the moment when developers realize that context itself doesn’t track changes. It’s a one-time delivery mechanism, not a live data stream. The value consumers receive is a snapshot from the moment they called getContext, and changes to the source don’t automatically propagate.

But here’s the good news: Svelte 5’s runes solve this elegantly. By understanding why plain context isn’t reactive and how $state fixes it, you’ll build context that updates everywhere, automatically.

This guide builds your understanding progressively through four stages:

  1. The Snapshot Problem — Why context captures frozen values and what that means for your data
  2. The Foundation — Using $state objects to create shared, reactive references
  3. The Golden Rule — Why mutation works and reassignment breaks everything
  4. Advanced Patterns — Getters for computed values and encapsulation

By the end, you’ll have a complete mental model for reactive context that scales from simple counters to complex application state.


The Problem Space: Why Context Captures Snapshots

Before we solve the problem, let’s understand it deeply. This isn’t academic—knowing why context values don’t update will help you debug issues, design better APIs, and avoid the frustrating “why isn’t this working?” moments that plague developers new to Svelte context.

The Frozen Counter Problem

Let’s start with code that looks perfectly reasonable but doesn’t work:

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

	let { children } = $props()

	let count = 0

	setContext('count', count) // Captures 0

	function increment() {
		count++ // count becomes 1, 2, 3...
		// But context still holds 0!
	}
</script>

<button onclick={increment}>
	Provider sees: {count}
</button>

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

	const count = getContext('count') // Gets 0, forever
</script>

<p>Consumer sees: {count}</p> <!-- Always 0, never updates! -->

The provider’s button shows 1, 2, 3… but the consumer is stuck at 0 forever. Click the button a hundred times—nothing changes in the consumer.

What Actually Happens Under the Hood

When setContext('count', count) executes during component initialization, JavaScript evaluates count at that exact moment and passes the result to Svelte. That’s it. There’s no magic link created, no subscription established, no ongoing connection maintained.

Let’s trace through the execution step by step:

// Inside Provider.svelte's <script>

let count = 0

setContext('count', count)
// What happens internally:
// 1. JavaScript evaluates the expression 'count'
// 2. Since count is 0, the expression evaluates to the NUMBER 0
// 3. Svelte receives: setContext('count', 0)
// 4. Svelte stores in its context map: { 'count': 0 }
// 5. Done. The variable 'count' and the stored value are now independent.

function increment() {
	count++
	// The variable 'count' changes: 0 → 1 → 2 → 3...
	// But the context store still has: { 'count': 0 }
	// There's no link between them!
}

The Photograph Mental Model

Think of setContext like taking a photograph. At the moment you take the photo, you capture exactly what the scene looks like. If the scene changes later—someone moves, the lighting shifts, years pass—your photograph doesn’t update. It’s frozen in time.

Time 0 (component initialization):
┌─────────────────┐       snapshot    ┌─────────────────┐
│ Provider        │   ─────────────>  │ Context Store   │
│ count = 0       │                   │ 'count' → 0     │
└─────────────────┘                   └─────────────────┘

Time 1, 2, 3... (after increments):
┌─────────────────┐                   ┌─────────────────┐
│ Provider        │    (no link)      │ Context Store   │
│ count = 1, 2, 3 │                   │ 'count' → 0     │  ← Still 0!
└─────────────────┘                   └─────────────────┘

The context store holds a “photograph” of the value at time 0. When the original variable changes, the photograph stays the same.

Why JavaScript Works This Way

This behavior isn’t a Svelte limitation—it’s fundamental to how JavaScript handles primitive values. When you pass a primitive (number, string, boolean) to a function, JavaScript copies the value:

let count = 0
const stored = count // stored gets the VALUE 0, not a reference to count

count = 100
console.log(count) // 100
console.log(stored) // Still 0!

For primitives, JavaScript always copies the value. There’s no way to create a “live reference” to a primitive—that’s simply not how the language works.

Objects Are Different (But Still Tricky)

Objects are passed by reference, which means something different happens:

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

	let { children } = $props()

	const user = { name: 'Alice', score: 0 }

	setContext('user', user)
	// Context stores a REFERENCE to the same object in memory

	function addPoint() {
		user.score++ // Modifying the shared object
	}
</script>

<button onclick={addPoint}>Add Point</button>
{@render children()}
<!-- Consumer.svelte -->
<script>
	import { getContext } from 'svelte'

	const user = getContext('user')
	// Gets a reference to the SAME object as the provider
</script>

<p>Score: {user.score}</p>

When provider calls addPoint(), the consumer’s user.score also changes—they’re pointing to the same object in memory. But there’s still a problem: Svelte doesn’t know the mutation happened. The consumer component won’t re-render to show the new score.

This is where $state comes in.


The Svelte 5 Mental Model: Reactive References

The key insight for reactive context is this: we need two things working together.

  1. A shared reference so both provider and consumer access the same data
  2. Svelte’s awareness so Svelte knows when the data changes and can update the UI

Using $state with an object gives us both.

The Fundamental Pattern

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

	let { children } = $props()

	// Step 1: Create reactive state as an OBJECT
	let counter = $state({ value: 0 })

	// Step 2: Share the reactive object via context
	setContext('counter', counter)

	function increment() {
		counter.value++ // Svelte tracks this mutation
	}
</script>

<button onclick={increment}>
	Provider: {counter.value}
</button>

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

	// Step 3: Get the same reactive object
	const counter = getContext('counter')
</script>

<p>Consumer: {counter.value}</p>
<!-- Now this updates automatically! -->

Click the button and watch both numbers increase together. The consumer is no longer frozen.

Why This Works: Tracing the Data Flow

Let’s trace through what happens with this pattern:

Time 0 (initialization):
┌──────────────────┐              ┌─────────────────┐
│ Provider         │   reference  │ Context Store   │
│ counter          ┼─────────────>│ 'counter' ───┐  │
└──────────────────┘              └──────────────┼──┘

                    ┌────────────────────────────┘


            { value: 0 }  ← Reactive $state object in memory


┌──────────────────┐│
│ Consumer         ││
│ counter ─────────┘│
└───────────────────┘

Both Provider and Consumer point to the SAME object in memory.

When provider mutates counter.value:

  1. Svelte’s reactivity system detects the change (because the object was created with $state)
  2. Svelte marks any component reading counter.value as needing re-render
  3. Consumer re-renders with the new value

The magic ingredient is $state. Without it, you’d have a shared object, but Svelte wouldn’t know when it changed.

Why Objects, Not Primitives?

Objects are passed by reference in JavaScript. When you setContext('counter', counter), the context stores a reference to the object—not a copy. The provider, the context, and all consumers point to the same object in memory. Mutate it anywhere, and everyone sees the change.

Why Primitives Don’t Work Directly

A simple primitive like let count = $state(0) won’t work for context because you can’t share a reference to it:

<!-- AVOID: This doesn't work as expected -->
<script>
	import { setContext } from 'svelte'

	let count = $state(0)

	setContext('count', count)
	// This stores the VALUE 0, not a reference to the $state
	// It's the same snapshot problem all over again!
</script>

Always wrap in an object:

<!-- ✅ This works -->
<script>
	import { setContext } from 'svelte'

	let counter = $state({ value: 0 })

	setContext('counter', counter)
	// This stores a REFERENCE to the $state object
</script>

This might feel verbose at first, but it’s explicit and predictable. You always know that context values are objects you can mutate.


The Golden Rule: Mutate, Don’t Reassign

Now for the most important rule in reactive context—the one that trips up developers more than any other. Understanding this deeply will save you hours of debugging.

When you have a reactive context object, you must mutate its properties. You must never reassign the entire object.

This rule is non-negotiable. Violate it, and your consumers will silently stop receiving updates.

Understanding Mutation

Mutation means changing a property on an existing object—the object’s identity stays the same, you’re just changing what’s inside it:

const user = $state({ name: 'Alice', score: 0 })

// ✅ Mutation: Changing existing properties
user.name = 'Bob'
user.score = 100

// ✅ Mutation: Adding new properties
user.email = 'bob@example.com'

// ✅ Mutation: Changing nested properties
user.settings = { theme: 'dark' }
user.settings.theme = 'light'

// ✅ Mutation: Array operations that modify in-place
user.friends = []
user.friends.push('Charlie')
user.friends.splice(0, 1)

Understanding Reassignment

Reassignment means pointing the variable at a completely new object—this is what breaks context:

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

// ❌ Reassignment: Creating a new object
user = { name: 'Bob', score: 100 }

// ❌ Reassignment: Even with spread operator
user = { ...user, score: 100 }

// ❌ Reassignment: Creating fresh object from JSON
user = JSON.parse(JSON.stringify(user))

// ❌ Reassignment: Filter/map/etc return new arrays
user.friends = user.friends.filter((f) => f !== 'Dave')

After reassignment, the variable points to a different object entirely.

Why Reassignment Breaks Context

Remember: both provider and consumer hold references to the same object. When you reassign, you break that shared reference.

With mutation (correct behavior):

Before:
Provider.user ──────┐
                    ├──> { name: 'Alice', score: 0 }  (object at memory address 0x1234)
Consumer.user ──────┘

After user.score = 100:
Provider.user ──────┐
                    ├──> { name: 'Alice', score: 100 }  (same object at 0x1234, mutated)
Consumer.user ──────┘

Both still point to the same object. Consumer sees the change.

With reassignment (broken behavior):

Before:
Provider.user ──────┐
                    ├──> { name: 'Alice', score: 0 }  (object at 0x1234)
Consumer.user ──────┘

After user = { name: 'Bob', score: 100 }:
Provider.user ──────────> { name: 'Bob', score: 100 }  (NEW object at 0x5678)

Consumer.user ──────────> { name: 'Alice', score: 0 }  (OLD object at 0x1234, orphaned!)

Consumer is now looking at an abandoned object. It will NEVER see updates again.

A Real-World Scenario: Settings Provider

Let’s see how this plays out in a realistic example that you might actually build:

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

	let { children } = $props()

	let settings = $state({
		theme: 'light',
		fontSize: 16,
		language: 'en',
		notifications: true
	})

	setContext('settings', settings)

	// ✅ CORRECT: Individual property changes
	function setDarkTheme() {
		settings.theme = 'dark'
	}

	function increaseFontSize() {
		settings.fontSize += 2
	}

	function toggleNotifications() {
		settings.notifications = !settings.notifications
	}

	// ❌ WRONG: Resetting by reassignment
	function resetSettingsBroken() {
		settings = { theme: 'light', fontSize: 16, language: 'en', notifications: true }
		// Provider now has a NEW object
		// All consumers still reference the OLD object
		// Future settings updates will NEVER reach consumers!
	}

	// ✅ CORRECT: Resetting by mutation
	function resetSettingsCorrect() {
		settings.theme = 'light'
		settings.fontSize = 16
		settings.language = 'en'
		settings.notifications = true
		// Same object, mutated properties
		// All consumers see the reset
	}

	// ✅ ALSO CORRECT: Using Object.assign
	function resetWithAssign() {
		Object.assign(settings, {
			theme: 'light',
			fontSize: 16,
			language: 'en',
			notifications: true
		})
		// Object.assign mutates the first argument
		// Same object, consumers see the change
	}
</script>

{@render children()}

Common Patterns That Accidentally Reassign

Watch out for these patterns that look innocent but break context:

let state = $state({ items: [] })

// ❌ Spread into new object
state = { ...state, items: [...state.items, newItem] }

// ✅ Push into existing array
state.items.push(newItem)

// ❌ Filter returns new array, then reassigns
state.items = state.items.filter((i) => i.id !== id)

// ✅ In-place array mutation with splice
const index = state.items.findIndex((i) => i.id === id)
if (index !== -1) state.items.splice(index, 1)

// ❌ Map returns new array
state.items = state.items.map((i) => (i.id === id ? { ...i, done: true } : i))

// ✅ Find and mutate directly
const item = state.items.find((i) => i.id === id)
if (item) item.done = true

// ❌ Creating fresh object from fetch response
const data = await response.json()
state = data // Reassignment!

// ✅ Merging fetch data into existing object
const data = await response.json()
Object.assign(state, data)

When You Need Full Object Replacement

Sometimes you legitimately need to replace an entire object—for example, replacing user data after login. In this case, wrap your data one level deeper:

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

	let { children } = $props()

	// Outer container NEVER gets reassigned
	let container = $state({
		user: null
	})

	setContext('user', container)

	async function login(credentials) {
		const response = await fetch('/api/login', {
			method: 'POST',
			body: JSON.stringify(credentials)
		})
		const userData = await response.json()

		// ✅ Mutating container.user property
		// Even though we're replacing the whole user object,
		// we're doing it by mutating the container
		container.user = userData
	}

	function logout() {
		// ✅ Mutating container.user property
		container.user = null
	}
</script>

{@render children()}

Consumers access via container.user:

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

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

{#if userContext.user}
	<p>Welcome, {userContext.user.name}!</p>
	<p>Email: {userContext.user.email}</p>
{:else}
	<p>Please log in</p>
{/if}

The outer container stays the same; you replace what’s inside it. The reference chain remains intact.


Advancing to Getters: The Reactivity Connection

You’ve mastered the basic pattern of $state objects. Now let’s explore a more elegant approach that you’ll see in production code: getter functions. Understanding why getters work unlocks cleaner, more flexible context APIs.

The Problem with Direct Property Assignment

Consider this seemingly reasonable context setup:

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

	let { children } = $props()

	let theme = $state('light')

	// ❌ This looks right but captures the VALUE at this moment
	setContext('theme', {
		current: theme, // Captures 'light' forever!
		toggle() {
			theme = theme === 'light' ? 'dark' : 'light'
		}
	})
</script>

{@render children()}

When this code runs, current is assigned the value 'light'—a string primitive. Even when theme changes to 'dark', current still holds 'light':

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

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

<button onclick={theme.toggle}> Toggle Theme </button>

<p>Current: {theme.current}</p>
<!-- Always 'light', even after toggling! -->

The toggle function works—it changes the theme variable in the provider. But theme.current is frozen because it captured a primitive value at object creation time, not a live reference.

The Getter Solution

A getter is a function that runs each time you access the property. It reads the reactive state fresh, returning whatever the current value is:

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

	let { children } = $props()

	let theme = $state('light')

	// ✅ Getter reads the CURRENT value each time it's accessed
	setContext('theme', {
		get current() {
			return theme // Evaluates `theme` each time you read `current`
		},
		toggle() {
			theme = theme === 'light' ? 'dark' : 'light'
		}
	})
</script>

{@render children()}

Now when consumers access theme.current, JavaScript calls the getter function, which reads the current value of the $state variable. If the state changed, you get the new value.

Visual Comparison

WITHOUT GETTER (property assignment):
┌──────────────────────┐     setContext     ┌──────────────────────────────┐
│ let theme = 'light'  │ ────────────────>  │ { current: 'light' }         │
│                      │                    │                              │
│ theme = 'dark'       │    (later)         │ { current: 'light' }  ← STALE!
└──────────────────────┘                    └──────────────────────────────┘

WITH GETTER:
┌──────────────────────┐     setContext     ┌──────────────────────────────┐
│ let theme = $state() │ ────────────────>  │ { get current() {            │
│                      │                    │     return theme;            │
│ (every read of       │                    │   }                          │
│  theme.current)──────│────────────────────│───> runs getter, returns     │
│                      │                    │       current theme value    │
└──────────────────────┘                    └──────────────────────────────┘

Complete Theme System with Getters

Here’s a full example showing the getter pattern in action:

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

	let { initialTheme = 'light', children } = $props()

	let theme = $state(initialTheme)

	// Context object with getters for reactive access
	const themeContext = {
		// Getter: reads fresh value each time
		get current() {
			return theme
		},

		// Getter: computed property
		get isDark() {
			return theme === 'dark'
		},

		// Getter: another computed property
		get cssClass() {
			return `theme-${theme}`
		},

		// Actions: methods that mutate state
		set(newTheme) {
			theme = newTheme
		},

		toggle() {
			theme = theme === 'light' ? 'dark' : 'light'
		}
	}

	setContext('theme', themeContext)

	// Side effect: apply theme to document
	$effect(() => {
		document.documentElement.setAttribute('data-theme', theme)
	})
</script>

{@render children()}
<!-- ThemeToggle.svelte -->
<script>
	import { getContext } from 'svelte'

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

<button onclick={theme.toggle}>
	{theme.isDark ? '☀️ Switch to Light' : '🌙 Switch to Dark'}
</button>

<p>Current theme: {theme.current}</p>
<!-- ThemedCard.svelte -->
<script>
	import { getContext } from 'svelte'

	let { children } = $props()

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

<div class="card" class:dark={theme.isDark}>
	{@render children()}
</div>

<style>
	.card {
		background: white;
		color: black;
		padding: 1rem;
		border-radius: 8px;
		transition:
			background 0.2s,
			color 0.2s;
	}

	.card.dark {
		background: #1a1a1a;
		color: white;
	}
</style>

All consumers react to theme changes automatically because they access theme.current, theme.isDark, and theme.cssClass through getters that read the reactive $state.

Getters vs Object Mutation: When to Use Which

Both patterns work for reactive context, but they have different strengths:

Object mutation pattern (simpler, direct state access):

<script>
	let state = $state({ theme: 'light', fontSize: 16 })
	setContext('settings', state)

	// Consumers read: settings.theme, settings.fontSize
	// Provider updates: state.theme = 'dark'
</script>

Best when:

  • Your context is a straightforward data container
  • Consumers need direct access to all properties
  • You don’t need computed/derived values

Getter pattern (more control, encapsulation):

<script>
	let theme = $state('light')
	let fontSize = $state(16)

	setContext('settings', {
		get theme() {
			return theme
		},
		get fontSize() {
			return fontSize
		},
		get isDark() {
			return theme === 'dark'
		}, // Computed!
		setTheme(t) {
			theme = t
		},
		increaseFontSize() {
			fontSize += 2
		}
	})
</script>

Best when:

  • You need computed/derived properties (like isDark)
  • You want to hide the internal state structure
  • You want to control how state is modified (via methods only)
  • You have multiple related state variables that shouldn’t all be directly exposed

Avoiding $effect Overuse

A common mistake is using $effect to “sync” context values. This is almost always wrong. Understanding when $effect is appropriate—and when it isn’t—will help you write cleaner, more predictable code.

The Wrong Way: Syncing with $effect

<script>
	import { setContext } from 'svelte'

	let { user } = $props()

	let contextUser = $state(user)

	// ❌ Don't do this!
	$effect(() => {
		contextUser = user // Trying to "sync" props to context
	})

	setContext('user', {
		get current() {
			return contextUser
		}
	})
</script>

Why This Is Wrong

This pattern has multiple problems:

  1. Extra indirection — You’re creating a copy of state that mirrors another
  2. Timing issues — Effects run after render, causing potential flicker
  3. Unnecessary complexity — There’s a much simpler solution
  4. Potential infinite loops — In complex cases, you can create circular updates

The Right Way: Direct Getter

If the data comes from props, just use the prop directly in a getter:

<script>
	import { setContext } from 'svelte'

	let { user, children } = $props()

	// ✅ Getter reads prop directly
	setContext('user', {
		get current() {
			return user
		}
	})
</script>

{@render children()}

No $state, no $effect, just a getter that reads the prop. When user changes (because the parent re-renders with new data), the getter returns the new value automatically.

When $effect IS Appropriate

Effects are for side effects—things that happen outside the Svelte component model:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let theme = $state('light')

	setContext('theme', {
		get current() {
			return theme
		},
		set(value) {
			theme = value
		}
	})

	// ✅ Side effect: Update the DOM outside Svelte's control
	$effect(() => {
		document.documentElement.setAttribute('data-theme', theme)
	})

	// ✅ Side effect: Persist to localStorage
	$effect(() => {
		localStorage.setItem('theme', theme)
	})

	// ✅ Side effect: Log analytics
	$effect(() => {
		console.log('Theme changed to:', theme)
		// Or: analytics.track('theme_changed', { theme });
	})
</script>

{@render children()}

Use $effect when you need to:

  • Manipulate the DOM directly
  • Interact with browser APIs (localStorage, sessionStorage, etc.)
  • Call external services or APIs
  • Set up subscriptions or event listeners
  • Perform logging or analytics

The Three-Layer Mental Model

To solidify your understanding, think of reactive context in three distinct layers:

┌─────────────────────────────────────────────────────────────┐
│  LAYER 1: STATE (The Source of Truth)                       │
│  ────────────────────────────────────                       │
│  $state variables hold the actual data                      │
│                                                             │
│  let theme = $state('light');                               │
│  let items = $state([]);                                    │
│  let settings = $state({ fontSize: 16, lang: 'en' });       │
│                                                             │
│  This is where data lives. Only one copy exists.            │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│  LAYER 2: CONTEXT OBJECT (The API)                          │
│  ─────────────────────────────────                          │
│  Getters expose state, methods mutate it                    │
│                                                             │
│  setContext('app', {                                        │
│    get theme() { return theme; },                           │
│    get isDark() { return theme === 'dark'; },               │
│    setTheme(t) { theme = t; }                               │
│  });                                                        │
│                                                             │
│  This shapes what consumers see and how they interact.      │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│  LAYER 3: CONSUMERS (The Users)                             │
│  ──────────────────────────────                             │
│  Read through getters, call methods, trigger UI updates     │
│                                                             │
│  const app = getContext('app');                             │
│  <p>{app.theme}</p>  <!-- Re-renders when theme changes --> │
│  <button onclick={() => app.setTheme('dark')}>Dark</button> │
│                                                             │
│  Components read and respond. They don't manage state.      │
└─────────────────────────────────────────────────────────────┘

Each layer has a specific responsibility:

  • Layer 1 holds truth — $state is the single source
  • Layer 2 shapes the API — getters expose what consumers need, methods control how state changes
  • Layer 3 consumes reactively — components read and respond to changes

Common Mistakes and Anti-Patterns

Mistake: Providing Primitives Directly
<script>
	let count = $state(0)
	setContext('count', count) // AVOID: Captures 0, not the reactive state
</script>

Why it breaks: Primitives are copied by value. The context stores the number 0, not a reference to your $state.

Fix: Wrap in an object with a getter:

<script>
	let count = $state(0)
	setContext('count', {
		get value() {
			return count
		},
		set(v) {
			count = v
		}
	})
</script>
Mistake: Reassigning Instead of Mutating
<script>
	let state = $state({ count: 0 })
	setContext('counter', state)

	function reset() {
		// ❌ This breaks the reference!
		state = { count: 0 }
	}
</script>

Why it breaks: The context holds a reference to the original object. When you reassign state, you create a new object. The context still points to the old one.

Fix: Mutate the existing object:

<script>
	function reset() {
		state.count = 0 // ✅ Mutate the property
	}
</script>
Mistake: Using Array Methods That Return New Arrays
<script>
	let state = $state({ items: [1, 2, 3] })
	setContext('list', state)

	function removeFirst() {
		// ❌ filter() returns a NEW array
		state.items = state.items.filter((_, i) => i !== 0)
	}
</script>

Why it breaks: While you’re mutating state.items (which is fine), you’re replacing the array reference. This works for direct consumers of state.items, but if any consumer cached a reference to the old array, they’ll have stale data.

Fix: Use in-place mutation:

<script>
	function removeFirst() {
		state.items.splice(0, 1) // ✅ Mutates array in place
	}
</script>
Mistake: Using $effect to Sync State
<script>
	let { user } = $props()
	let contextUser = $state(user)

	// ❌ Don't do this!
	$effect(() => {
		contextUser = user // Trying to "sync" props to context
	})

	setContext('user', {
		get current() {
			return contextUser
		}
	})
</script>

Why it’s wrong: Extra indirection, timing issues (effects run after render), and unnecessary complexity.

Fix: Use a getter that reads the prop directly:

<script>
	let { user } = $props()

	// ✅ Getter reads prop directly
	setContext('user', {
		get current() {
			return user
		}
	})
</script>

Verifying Your Understanding

Let’s test your grasp of these concepts. For each scenario, predict whether the consumer will see updates.

Scenario 1

<!-- Provider -->
<script>
  let data = $state({ count: 0 });
  setContext('data', data);

  function increment() {
    data.count++;
  }
</script>

<!-- Consumer -->
<script>
  const data = getContext('data');
</script>
<p>{data.count}</p>

Answer: ✅ Yes, it updates. data.count++ mutates the existing object. Both provider and consumer reference the same $state object.

Scenario 2

<!-- Provider -->
<script>
  let count = $state(0);
  setContext('count', count);

  function increment() {
    count++;
  }
</script>

<!-- Consumer -->
<script>
  const count = getContext('count');
</script>
<p>{count}</p>

Answer: ❌ No, it doesn’t update. Primitives are copied by value. Consumer got 0 at initialization and never sees changes.

Scenario 3

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

  function changeName() {
    user = { name: 'Bob' };  // Reassignment
  }
</script>

<!-- Consumer -->
<script>
  const user = getContext('user');
</script>
<p>{user.name}</p>

Answer: ❌ No, it doesn’t update. Reassignment creates a new object. Consumer still references the old object showing ‘Alice’.

Scenario 4

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

  function changeName() {
    user.name = 'Bob';  // Mutation
  }
</script>

<!-- Consumer -->
<script>
  const user = getContext('user');
</script>
<p>{user.name}</p>

Answer: ✅ Yes, it updates. user.name = 'Bob' mutates the existing object. Both still reference the same object.

Scenario 5

<!-- Provider -->
<script>
  let theme = $state('light');
  setContext('theme', {
    get current() { return theme; },
    toggle() { theme = theme === 'light' ? 'dark' : 'light'; }
  });
</script>

<!-- Consumer -->
<script>
  const theme = getContext('theme');
</script>
<p>{theme.current}</p>

Answer: ✅ Yes, it updates. The getter reads theme fresh each time. When toggle() changes theme, the next read of theme.current returns the new value.


Complete Production Example

Let’s put everything together in a comprehensive example—a score tracker that demonstrates all the patterns we’ve covered using the getter pattern.

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

	let { children } = $props()

	// Layer 1: State (source of truth)
	let points = $state(0)
	let multiplier = $state(1)
	let history = $state([])

	// Layer 2: Context object (the API)
	const scoreContext = {
		// Getters for reactive access
		get points() {
			return points
		},

		get multiplier() {
			return multiplier
		},

		get history() {
			return history
		},

		// Computed properties via getters
		get totalWithMultiplier() {
			return points * multiplier
		},

		get hasHistory() {
			return history.length > 0
		},

		// Methods for controlled mutations
		addPoints(amount) {
			const earned = amount * multiplier
			points += earned
			history.push({
				timestamp: Date.now(),
				amount: earned,
				total: points
			})
		},

		doubleMultiplier() {
			multiplier *= 2
		},

		reset() {
			points = 0
			multiplier = 1
			history.length = 0 // Clear array in place
		}
	}

	setContext('score', scoreContext)
</script>

{@render children()}
<!-- ScoreDisplay.svelte -->
<script>
	import { getContext } from 'svelte'

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

<div class="score-display">
	<h2>Score: {score.points}</h2>
	<p>Multiplier: {score.multiplier}×</p>
	<p>Effective total: {score.totalWithMultiplier}</p>
</div>

<style>
	.score-display {
		padding: 1rem;
		background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
		color: white;
		border-radius: 8px;
		text-align: center;
	}

	h2 {
		margin: 0 0 0.5rem;
		font-size: 2rem;
	}

	p {
		margin: 0.25rem 0;
		opacity: 0.9;
	}
</style>
<!-- ScoreControls.svelte -->
<script>
	import { getContext } from 'svelte'

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

<div class="controls">
	<button onclick={() => score.addPoints(10)}>+10 points</button>
	<button onclick={() => score.addPoints(50)}>+50 points</button>
	<button onclick={() => score.doubleMultiplier()}>Double Multiplier</button>
	<button onclick={() => score.reset()} class="reset">Reset</button>
</div>

<style>
	.controls {
		display: flex;
		gap: 0.5rem;
		flex-wrap: wrap;
	}

	button {
		padding: 0.5rem 1rem;
		border: none;
		border-radius: 4px;
		background: #3b82f6;
		color: white;
		cursor: pointer;
		font-weight: 500;
	}

	button:hover {
		background: #2563eb;
	}

	button.reset {
		background: #ef4444;
	}

	button.reset:hover {
		background: #dc2626;
	}
</style>
<!-- ScoreHistory.svelte -->
<script>
	import { getContext } from 'svelte'

	const score = getContext('score')

	function formatTime(timestamp) {
		return new Date(timestamp).toLocaleTimeString()
	}
</script>

<div class="history">
	<h3>History ({score.history.length} entries)</h3>

	{#if score.hasHistory}
		<ul>
			{#each score.history as entry, i (entry.timestamp)}
				<li>
					<span class="index">#{i + 1}</span>
					<span class="amount">+{entry.amount}</span>
					<span class="total">{entry.total} total</span>
					<span class="time">{formatTime(entry.timestamp)}</span>
				</li>
			{/each}
		</ul>
	{:else}
		<p class="empty">No points scored yet</p>
	{/if}
</div>

<style>
	.history {
		padding: 1rem;
		background: #f3f4f6;
		border-radius: 8px;
	}

	h3 {
		margin: 0 0 0.75rem;
		color: #374151;
	}

	ul {
		list-style: none;
		padding: 0;
		margin: 0;
	}

	li {
		display: flex;
		gap: 0.75rem;
		padding: 0.5rem 0;
		border-bottom: 1px solid #e5e7eb;
		font-size: 0.875rem;
	}

	li:last-child {
		border-bottom: none;
	}

	.index {
		color: #9ca3af;
		font-weight: 500;
	}

	.amount {
		color: #059669;
		font-weight: 600;
	}

	.total {
		color: #374151;
	}

	.time {
		margin-left: auto;
		color: #9ca3af;
		font-size: 0.75rem;
	}

	.empty {
		color: #9ca3af;
		font-style: italic;
		margin: 0;
	}
</style>
<!-- +page.svelte -->
<script>
	import ScoreProvider from './ScoreProvider.svelte'
	import ScoreDisplay from './ScoreDisplay.svelte'
	import ScoreControls from './ScoreControls.svelte'
	import ScoreHistory from './ScoreHistory.svelte'
</script>

<ScoreProvider>
	<main>
		<h1>Score Tracker</h1>
		<ScoreDisplay />
		<ScoreControls />
		<ScoreHistory />
	</main>
</ScoreProvider>

<style>
	main {
		max-width: 600px;
		margin: 2rem auto;
		padding: 1rem;
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	h1 {
		text-align: center;
		color: #1f2937;
	}
</style>

All three consumer components share the same context. When ScoreControls calls score.addPoints(), both ScoreDisplay and ScoreHistory automatically re-render to show the updated values.


Quick Reference Table

PatternWorks?Why
$state({ value: 0 }) + value++Object mutation, Svelte tracks it
$state(0) directly in contextPrimitive copied by value
object.property = newValueMutation preserves reference
object = { ...object }Reassignment breaks reference
array.push(item)In-place mutation
array = array.filter(...)Creates new array
array.splice(index, 1)In-place mutation
Object.assign(obj, newData)Mutates first argument
get prop() { return $state }Getter reads fresh value
prop: $state in object literalCaptures value at creation

Performance and Scaling Considerations

Reactive context is efficient, but understanding its performance characteristics helps you scale confidently.

Granular Updates

Svelte’s reactivity is granular. When you mutate a specific property, only components reading that specific value re-render—not every component reading any part of the context object. This means you can have complex state objects without worrying about cascading updates.

Large Arrays and Objects

For large arrays (hundreds or thousands of items), consider using a Map for O(1) lookup instead of iterating:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let items = $state([])
	let itemsById = new Map()

	const listContext = {
		get items() {
			return items
		},

		add(item) {
			items.push(item)
			itemsById.set(item.id, item)
		},

		getById(id) {
			return itemsById.get(id)
		},

		updateById(id, updates) {
			const item = itemsById.get(id)
			if (item) {
				Object.assign(item, updates) // Mutate in place
			}
		}
	}

	setContext('list', listContext)
</script>

{@render children()}

Memory Considerations

Context objects persist for the lifetime of the provider component. If you’re storing large amounts of data, consider cleanup strategies:

<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	let cache = $state(new Map())
	const maxSize = 100

	const cacheContext = {
		set(key, value) {
			// Evict oldest if at capacity
			if (cache.size >= maxSize) {
				const firstKey = cache.keys().next().value
				cache.delete(firstKey)
			}
			cache.set(key, value)
		},

		get(key) {
			return cache.get(key)
		}
	}

	setContext('cache', cacheContext)
</script>

{@render children()}

Conclusion

Reactive context isn’t magic—it’s the natural consequence of understanding how JavaScript handles values and how Svelte tracks changes. When you see context “not working,” you’re almost always seeing one of two things: primitives being copied by value instead of referenced, or reassignment breaking shared references.

The $state object pattern solves both problems elegantly. By wrapping your data in an object and sharing that object through context, you establish a single source of truth that multiple components can both read and modify. Svelte’s reactivity system handles the rest—tracking which components depend on which properties and re-rendering them when those properties change.

The getter pattern takes this further, allowing you to expose computed properties and hide internal implementation details while maintaining clean, predictable APIs.

The golden rule of “mutate, don’t reassign” might feel restrictive at first, but it’s actually liberating once you internalize it. You never have to wonder whether your context is synced across components. As long as you’re mutating the same object, everyone sees the same data. It’s a simple mental model that scales from trivial counters to complex application state.

With these fundamentals in place, you’re equipped to build context that stays synchronized across your entire component tree—the foundation for everything from theme systems to shopping carts to real-time collaboration features.


Key Takeaways

Plain context captures a snapshot. When you call setContext(key, value), Svelte stores that value as-is. For primitives, that’s a copy. For objects, it’s a reference. Either way, there’s no ongoing connection—changes to the original variable don’t automatically update the stored value.

$state with objects creates reactivity. By wrapping your data in a $state object and sharing that object via context, both provider and consumer hold references to the same reactive object. When you mutate the object’s properties, Svelte tracks the change and updates all components that read those properties.

Mutate properties, never reassign the object. This is the golden rule. Mutation preserves the shared reference. Reassignment creates a new object, leaving consumers holding an orphaned reference to the old data. Always change properties (obj.prop = newValue), never replace the whole object (obj = newObject).

Getters ensure freshness. Use getter functions (get prop()) when you need computed values or when you want to encapsulate internal state. Getters run on each access, always returning the current value.

Avoid $effect for syncing. If data comes from props, use a getter. Reserve $effect for actual side effects like DOM manipulation, localStorage persistence, or external API calls.

Think in three layers. State (source of truth) → Context Object (the API) → Consumers (the users). Each layer has a specific job, and keeping them separate makes your code maintainable.


What’s Next

Build on these fundamentals with advanced patterns in Class-Based Context, where you’ll learn how to use TypeScript classes with $state for encapsulated, type-safe context that scales to complex applications.


See Also

Official Documentation