Reactive State That Just Works

The $state rune is the foundation of Svelte 5’s reactivity system. It transforms ordinary JavaScript variables into reactive state that automatically triggers UI updates when changed. Unlike other frameworks that require wrapper objects or setter functions, Svelte lets you work with reactive values just like regular variables—the compiler handles the magic behind the scenes.

This tutorial takes you from basic usage through advanced patterns, covering deep reactivity, proxy mechanics, performance optimization with $state.raw, and the common pitfalls that trip up even experienced developers.


What Is $state?

$state is a rune that makes a variable reactive. When a reactive variable changes, Svelte automatically updates any part of your UI that uses it.

Basic Usage

Here’s the simplest possible example:

<script>
	let count = $state(0)
</script>

<button onclick={() => count++}>
	Clicks: {count}
</button>

Let’s break this down piece by piece:

  1. let count = $state(0) - Creates a reactive variable called count with an initial value of 0
  2. onclick={() => count++} - When clicked, increment count by 1
  3. {count} - Display the current value of count in the button text

What happens when you click:

  1. The click handler runs count++
  2. Svelte detects that count changed
  3. Svelte automatically updates the button text to show the new value

You never have to manually update the DOM. That’s the power of reactivity!

Why $state Is Different from Other Frameworks

If you’ve used React, Vue, or other frameworks, you might be used to syntax like this:

// React
const [count, setCount] = useState(0)
setCount(count + 1) // Must use setter function

// Vue
const count = ref(0)
count.value++ // Must use .value

Svelte is different. With $state, you interact with the variable directly:

// Svelte 5
let count = $state(0)
count++ // Just use it like a normal variable!

No wrapper objects, no setter functions, no .value. It’s just a number, and you use it like a number. This is Svelte’s philosophy: keep things simple and close to vanilla JavaScript.

How It Works Under the Hood (Simplified)

When you write let count = $state(0), the Svelte compiler transforms your code. Here’s a simplified version of what happens:

// What you write:
let count = $state(0)
console.log(count)
count++

// What Svelte compiles it to (conceptually):
let count_signal = createSignal(0)

console.log(getSignalValue(count_signal)) // Reading
setSignalValue(count_signal, getSignalValue(count_signal) + 1) // Writing

The compiler wraps every read and write of the variable with special functions that:

  • Track which parts of the UI depend on this variable
  • Notify those parts when the variable changes

Why does this matter? It explains why runes only work in .svelte files and .svelte.js/.svelte.ts files—these are the only files the Svelte compiler processes.

Where Can You Use $state?

You can use $state in:

LocationExample
Component <script> blockslet name = $state('Alice')
.svelte.js / .svelte.ts filesFor shared reactive state
Class fieldsclass Counter { count = $state(0) }

You cannot use $state in:

  • Regular .js or .ts files (compiler doesn’t process them)
  • Outside of the <script> block in a .svelte file

Common Beginner Questions

Q: What if I don’t use $state?

Without $state, changes won’t update the UI:

<script>
	let count = 0 // NOT reactive!
</script>

<button onclick={() => count++}>
	Clicks: {count}
	<!-- Will always show 0! -->
</button>

The variable changes internally, but Svelte doesn’t know about it, so the UI never updates.

Q: Can I use $state with any value type?

Yes! $state works with any JavaScript value:

<script>
	let count = $state(0) // Number
	let name = $state('Alice') // String
	let isActive = $state(true) // Boolean
	let user = $state({ name: 'Bob' }) // Object
	let items = $state([1, 2, 3]) // Array
	let nothing = $state(null) // null
</script>

Deep Reactivity: The Proxy Magic

What Is Deep Reactivity?

When you pass an object or array to $state, something special happens: Svelte makes it deeply reactive. This means changes to nested properties (no matter how deep) automatically trigger UI updates.

Understanding the Concept: A Simple Analogy

Think of deep reactivity like a security system in a building.

With shallow monitoring (regular variables), you only watch the front door. If someone enters through a window or back door, you don’t notice.

With deep monitoring ($state objects/arrays), you have cameras in every room, every hallway, every closet. Any change anywhere in the building triggers an alert.

How Deep Reactivity Works

When you create a reactive object:

<script>
	let user = $state({
		name: 'Alice',
		address: {
			city: 'New York',
			zip: '10001'
		}
	})
</script>

Svelte wraps the object in a Proxy. A Proxy is a JavaScript feature that intercepts operations on an object—like reading or writing properties.

Here’s what happens when you change a nested property:

<script>
	// Changing a deeply nested property
	user.address.city = 'Los Angeles'
</script>

<!-- This automatically updates! --><p>City: {user.address.city}</p>

Step by step:

  1. You write to user.address.city
  2. The Proxy intercepts this write operation
  3. Svelte records that user.address.city changed
  4. Svelte finds all UI elements that use user.address.city
  5. Svelte updates only those elements (not the entire page!)

Deep Reactivity with Arrays

Arrays get the same deep reactivity treatment, including their methods:

<script>
	let todos = $state([
		{ id: 1, text: 'Learn Svelte 5', done: false },
		{ id: 2, text: 'Build an app', done: false }
	])
</script>

<ul>
	{#each todos as todo}
		<li>
			<input type="checkbox" checked={todo.done} onchange={() => (todo.done = !todo.done)} />
			{todo.text}
		</li>
	{/each}
</ul>

<button onclick={() => todos.push({ id: 3, text: 'New task', done: false })}> Add Todo </button>

What’s amazing here:

  1. Array methods work: todos.push() triggers an update. So do pop(), splice(), shift(), etc.
  2. Nested objects are reactive: Changing todo.done updates just that checkbox
  3. New items become reactive: When you push a new object, it automatically becomes reactive too
  4. Granular updates: Checking one checkbox doesn’t re-render the entire list—only that specific item

Visual Representation of Deep Reactivity

$state({                          ← Proxy wrapper
  name: 'Alice',                  ← Reactive property
  preferences: {                  ← Nested Proxy wrapper
    theme: 'dark',                ← Reactive property
    notifications: {              ← Another nested Proxy wrapper
      email: true,                ← Reactive property
      push: false                 ← Reactive property
    }
  }
})

Every level of nesting gets its own Proxy wrapper, making all properties reactive.

Performance Tip

Deep reactivity enables granular updates. When you change user.address.city, only UI elements depending on that specific property re-render—not the entire component.

What Gets Proxified (And What Doesn’t)

Svelte wraps things in Proxies recursively until it hits something that’s not a plain object or array.

These ARE made deeply reactive:

<script>
	// Plain objects - YES
	let config = $state({ theme: 'dark' })

	// Arrays - YES
	let items = $state([1, 2, 3])

	// Nested combinations - YES
	let data = $state({
		users: [{ name: 'Alice', scores: [95, 87, 92] }]
	})
</script>

These are NOT made deeply reactive:

<script>
	// Class instances - NO
	class Person {
		constructor(name) {
			this.name = name
		}
	}
	let person = $state(new Person('Alice'))
	// person.name is NOT reactive!

	// Built-in objects - NO
	let date = $state(new Date())
	let map = $state(new Map())
	let set = $state(new Set())
	// Changes to these won't trigger updates!

	// Objects created with Object.create() - NO
	let weird = $state(Object.create(null))
</script>

Why the distinction?

Plain objects and arrays are the most common data structures, and Svelte can safely wrap them. But class instances have custom behavior (methods, getters, etc.) that Proxies might interfere with. Built-in objects like Map and Set have internal slots that Proxies can’t access.

Svelte’s Reactive Built-in Classes

Reactive Map, Set, Date and URL

Svelte ships SvelteMap, SvelteSet, SvelteDate, and SvelteURL in svelte/reactivity. Use these drop-in replacements whenever you need reactive collections or dates — they work exactly like their native counterparts.

For Map, Set, Date, and URL, Svelte provides reactive versions you can import:

<script>
	import { SvelteMap, SvelteSet, SvelteDate, SvelteURL } from 'svelte/reactivity'

	let myMap = new SvelteMap()
	let mySet = new SvelteSet()
	let myDate = new SvelteDate()
	let myUrl = new SvelteURL('https://example.com')
</script>

These work like their native counterparts but are fully reactive.


When Reactivity Breaks

Understanding when reactivity doesn’t work is just as important as knowing when it does. These are the most common mistakes developers make.

1: Destructuring Breaks Reactivity

Reactivity Lost at Destructuring

Destructuring a $state object gives you plain JavaScript values — not reactive references. Changes to the original object will not update the destructured variables.

What happens: When you destructure a reactive object, you extract the current values at that moment—not reactive references.

Analogy: Imagine taking a photo of a clock. The photo shows the time when you took it, but it doesn’t update as time passes. Destructuring is like taking a photo of your data.

<script>
	let todo = $state({ done: false, text: 'Learn Svelte' })

	// AVOID: This takes a "photo" of the current values
	let { done, text } = todo

	function toggle() {
		// This changes the original todo...
		todo.done = !todo.done

		// ...but 'done' variable still has the old value!
		console.log(done) // Still false!
		console.log(todo.done) // true!
	}
</script>

<!-- AVOID: This won't update when todo.done changes -->
<p>Done (broken): {done}</p>

<!-- PREFERRED: This WILL update -->
<p>Done (works): {todo.done}</p>

Why it happens:

let { done, text } = todo

This is equivalent to:

let done = todo.done // Gets the value false
let text = todo.text // Gets the value 'Learn Svelte'

done is now just a regular variable holding false. It has no connection to todo anymore.

The Fix: Always access properties through the reactive object.

<script>
	let todo = $state({ done: false, text: 'Learn Svelte' })

	// PREFERRED: Access through the object in your template
</script>

<p>Done: {todo.done}</p><p>Text: {todo.text}</p>

When destructuring IS okay:

Destructuring is fine when you need a one-time snapshot:

<script>
	let form = $state({ name: 'Alice', email: 'alice@example.com' })

	function submitForm() {
		// Destructure to get current values for submission
		const { name, email } = form

		// Send to server (we want the values at this moment)
		fetch('/api/submit', {
			method: 'POST',
			body: JSON.stringify({ name, email })
		})
	}
</script>

2: Reassigning vs. Mutating

What’s the difference?

  • Mutating: Changing something inside an object/array
  • Reassigning: Replacing the entire object/array with a new one
<script>
	let items = $state(['a', 'b', 'c'])

	// PREFERRED: MUTATING - modifying the existing array
	function addWithMutation() {
		items.push('d') // Works!
		items[0] = 'A' // Works!
		items.splice(1, 1) // Works!
	}

	// PREFERRED: REASSIGNING - replacing with a new array
	function addWithReassign() {
		items = [...items, 'd'] // Works!
		items = items.map((i) => i.toUpperCase()) // Works!
		items = items.filter((i) => i !== 'b') // Works!
	}
</script>

Both approaches work with $state. The difference matters for:

  1. Performance (see later section)
  2. $state.raw (only reassignment works)
  3. Code style preferences

Understanding the mental model:

MUTATION (modifying in place):
┌─────────────────┐
│ items = [a,b,c] │
└────────┬────────┘
         │ items.push('d')

┌─────────────────┐
│ items = [a,b,c,d] │  ← Same array, modified
└─────────────────┘

REASSIGNMENT (replacing entirely):
┌─────────────────┐
│ items = [a,b,c] │  ← Old array (garbage collected)
└─────────────────┘
         │ items = [...items, 'd']

┌─────────────────┐
│ items = [a,b,c,d] │  ← Brand new array
└─────────────────┘

3: Passing State to Functions (The Pass-by-Value Problem)

What happens: JavaScript passes values, not references. When you pass a $state value to a function, you’re passing the current value at that moment.

Analogy: It’s like telling someone a phone number verbally. If you later change your phone number, they still have the old one written down.

<script>
	function addNumbers(a, b) {
		return a + b
	}

	let x = $state(1)
	let y = $state(2)

	// AVOID: total is calculated ONCE with values 1 and 2
	let total = addNumbers(x, y)

	function increment() {
		x++
		y++
		// x is now 2, y is now 3
		// But total is STILL 3!
	}
</script>

<p>x: {x}, y: {y}</p>
<p>Total: {total}</p>
<!-- Always shows 3, never updates! -->

Why it happens:

When addNumbers(x, y) is called:

  1. x is evaluated to 1
  2. y is evaluated to 2
  3. The function receives the values 1 and 2
  4. It returns 3
  5. total is set to 3

Later changes to x and y don’t re-run addNumbers.

The Fix: Use $derived

<script>
	let x = $state(1)
	let y = $state(2)

	// PREFERRED: $derived recalculates whenever x or y changes
	let total = $derived(x + y)
</script>

<p>x: {x}, y: {y}</p>
<p>Total: {total}</p>
<!-- Updates correctly! -->

Alternative Fix: Pass an Object

If you need to pass state to a function, pass an object instead of primitives:

<script>
	function calculateTotal(data) {
		// Accesses current values each time it's called
		return data.x + data.y
	}

	let data = $state({ x: 1, y: 2 })

	// PREFERRED: This accesses the reactive object each time
	let total = $derived(calculateTotal(data))
</script>

4: Class Instances Aren’t Proxified

What happens: When you wrap a class instance in $state, the instance itself isn’t made reactive. Only plain objects and arrays get the Proxy treatment.

<script>
	class ShoppingCart {
		items = []

		addItem(item) {
			this.items.push(item)
		}

		get total() {
			return this.items.reduce((sum, item) => sum + item.price, 0)
		}
	}

	// AVOID:The class instance isn't reactive!
	let cart = $state(new ShoppingCart())

	function addProduct() {
		cart.addItem({ name: 'Widget', price: 9.99 })
		// UI won't update! cart.items.length still shows 0 in the UI
	}
</script>

<p>Items: {cart.items.length}</p>
<!-- Stuck at 0! -->

Why it happens: Classes often have methods, getters, setters, and internal logic that Proxies might interfere with. Svelte plays it safe by not proxifying them.

The Fix: Use $state inside class fields

<script>
	class ShoppingCart {
		// PREFERRED: Make the items array reactive with $state
		items = $state([])

		addItem(item) {
			this.items.push(item) // Now triggers updates!
		}

		// Getters automatically work with reactive data
		get total() {
			return this.items.reduce((sum, item) => sum + item.price, 0)
		}

		get itemCount() {
			return this.items.length
		}
	}

	let cart = new ShoppingCart() // No $state wrapper needed!
</script>

<p>Items: {cart.itemCount}</p>
<p>Total: ${cart.total.toFixed(2)}</p>

<button onclick={() => cart.addItem({ name: 'Widget', price: 9.99 })}> Add Widget </button>

Key insight: Put $state on the reactive fields, not on the class instance.

5: Exporting State Across Modules

Never Export Reassignable $state Directly

Exporting a $state primitive and reassigning it inside another module will not propagate the update. Export an object and mutate its properties instead — or use getter functions.

What happens: You can’t directly export a $state variable that gets reassigned.

// AVOID: state.svelte.js - This WON'T work!
export let count = $state(0)

export function increment() {
	count++ // Other files won't see this change!
}

Why it happens: The Svelte compiler transforms each file independently. When another file imports count, it gets a reference that the compiler in that file doesn’t know how to handle.

The Fix: Option 1 - Export an Object

// PREFERRED: state.svelte.js - Export an object instead
export const counter = $state({ count: 0 })

export function increment() {
	counter.count++ // Mutating a property works!
}
<!-- App.svelte -->
<script>
	import { counter, increment } from './state.svelte.js'
</script>

<button onclick={increment}>
	Count: {counter.count}
</button>

The Fix: Option 2 - Use Getter Functions

// PREFERRED: state.svelte.js - Don't export the variable directly
let count = $state(0)

export function getCount() {
	return count
}

export function increment() {
	count++
}
<!-- App.svelte -->
<script>
	import { getCount, increment } from './state.svelte.js'
</script>

<button onclick={increment}>
	Count: {getCount()}
</button>

6: The this Binding Problem in Classes

What happens: When you pass a class method as an event handler, this might not be what you expect.

<script>
	class Counter {
		count = $state(0)

		increment() {
			this.count++ // 'this' might not be the Counter!
		}
	}

	let counter = new Counter()
</script>

<!-- AVOID:'this' will be the button, not the counter! -->
<button onclick={counter.increment}>
	Count: {counter.count}
</button>

Why it happens: In JavaScript, this is determined by how a function is called, not where it’s defined. When Svelte calls counter.increment as an event handler, this becomes the button element.

The Fixes:

<script>
	class Counter {
		count = $state(0)

		// Fix 1: Use arrow function in the class
		increment = () => {
			this.count++ // Arrow functions capture 'this'
		}

		// Fix 2: Regular method (needs wrapper in template)
		add() {
			this.count++
		}
	}

	let counter = new Counter()
</script>

<!-- Fix 1: Works directly -->
<button onclick={counter.increment}>Increment</button>

<!-- Fix 2: Wrap in arrow function -->
<button onclick={() => counter.add()}>Add</button>

Shallow State with $state.raw

What Is $state.raw?

$state.raw creates reactive state that is NOT deeply proxified. Changes to properties inside the object/array won’t trigger updates—only reassigning the entire variable will.

When Would You Want This?

Analogy: Imagine you’re a librarian with two approaches to tracking books:

  1. Deep tracking ($state): You have a real-time sensor on every single book. You know the instant any book is moved, opened, or touched.

  2. Shallow tracking ($state.raw): You only track when entire shelves are replaced. You don’t know if individual books are moved around.

Deep tracking is precise but requires more sensors (memory/CPU). Shallow tracking is simpler and faster when you only care about wholesale changes.

How to Use $state.raw

<script>
	// Shallow reactive array
	let users = $state.raw([
		{ id: 1, name: 'Alice' },
		{ id: 2, name: 'Bob' }
	])

	// AVOID: This WON'T trigger an update
	function updateNameBroken() {
		users[0].name = 'Alicia' // No effect on UI!
	}

	// PREFERRED: This WILL trigger an update
	function updateNameWorks() {
		users = users.map((user) => (user.id === 1 ? { ...user, name: 'Alicia' } : user))
	}

	// PREFERRED: Replacing the entire array works
	async function fetchUsers() {
		users = await fetch('/api/users').then((r) => r.json())
	}
</script>

Why Use $state.raw?

Sometimes you don’t need deep reactivity. If you’re always replacing the entire object/array and never mutating it, $state.raw can be more efficient. It avoids the overhead of creating Proxies for every nested property. Lets take a look at some specific scenarios where $state.raw can be beneficial:

1: Performance with Large Data

Deep proxying has overhead. For large arrays (thousands of items) that you replace wholesale, $state.raw is faster.

<script>
	// Good candidate for $state.raw:
	// - Large dataset (thousands of rows)
	// - Data comes from an API (replaced entirely)
	// - You never mutate individual items
	let logEntries = $state.raw([])

	async function loadLogs() {
		// Replace the entire array with new data
		logEntries = await fetch('/api/logs').then((r) => r.json())
	}
</script>

2: Immutable Data Patterns

If you’re following immutable data principles (always creating new objects instead of mutating), $state.raw matches that style.

<script>
	let todos = $state.raw([])

	// Immutable add
	function addTodo(text) {
		todos = [...todos, { id: Date.now(), text, done: false }]
	}

	// Immutable toggle
	function toggleTodo(id) {
		todos = todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
	}

	// Immutable delete
	function deleteTodo(id) {
		todos = todos.filter((todo) => todo.id !== id)
	}
</script>

3: Integration with External Libraries

Some libraries expect plain objects, not Proxies. $state.raw gives you unproxied data.

$state.raw Can Contain Reactive State

Here’s a powerful pattern: a raw array where each item is individually reactive.

<script>
	// The array itself is raw (no proxy)
	let todos = $state.raw([])

	function addTodo(text) {
		// Each todo is individually reactive!
		const newTodo = $state({ text, done: false })

		// Must reassign the array (it's raw)
		todos = [...todos, newTodo]
	}
</script>

{#each todos as todo}
	<!-- Clicking this DOES work because todo itself is reactive -->
	<label>
		<input type="checkbox" bind:checked={todo.done} />
		{todo.text}
	</label>
{/each}

This gives you the best of both worlds:

  • Efficient array updates (only reassignment)
  • Granular item reactivity (each todo updates independently)

When to Use $state.raw

Use $state.raw for large arrays (1000+ items) that you replace entirely. It’s 3-5x faster because it skips deep proxy wrapping.


$state.snapshot: Getting Plain Objects

When you need to pass a reactive object to code that doesn’t expect Proxies (like JSON.stringify or external libraries), use $state.snapshot:

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

	function saveToServer() {
		// Get a plain object copy (no Proxy)
		const plainUser = $state.snapshot(user)

		// Now it's safe to use with external code
		fetch('/api/users', {
			method: 'POST',
			body: JSON.stringify(plainUser) // Works perfectly!
		})
	}

	function logUser() {
		// Logging a proxy shows "Proxy {}" which isn't helpful
		console.log(user) // Proxy { name: 'Alice', ... }

		// Snapshot gives you readable output
		console.log($state.snapshot(user)) // { name: 'Alice', preferences: { theme: 'dark' } }
	}
</script>

Performance Considerations

When Deep Reactivity Helps Performance

Deep reactivity enables granular updates. When you change a specific property, only UI elements depending on that property re-render.

<script>
	let todos = $state([
		{ id: 1, text: 'First todo', done: false },
		{ id: 2, text: 'Second todo', done: false }
		// Imagine 1000 todos...
	])
</script>

{#each todos as todo}
	<!-- When todo.done changes, ONLY this checkbox re-renders -->
	<input type="checkbox" bind:checked={todo.done} />
{/each}

Without deep reactivity (or with a framework that doesn’t have it), changing one checkbox might re-render the entire list!

When Deep Reactivity Hurts Performance

Deep proxying has overhead. For very large datasets that you replace entirely (never mutate), this overhead is wasted.

<script>
	// AVOID:Suboptimal: Creating proxies for 10,000 objects we never mutate
	let rows = $state(await fetch('/api/large-dataset').then((r) => r.json()))

	// PREFERRED: Raw state for data we only replace
	let rows = $state.raw(await fetch('/api/large-dataset').then((r) => r.json()))
</script>

When to Use $state vs $state.raw

Use $state when…Use $state.raw when…
You mutate individual propertiesYou always replace the entire value
Data is user-interactiveData comes from an API
Small to medium-sized dataLarge datasets (1000+ items)
You need granular updatesYou only need wholesale updates

Practical Examples

1: Shopping Cart

A complete shopping cart demonstrating $state, $derived, and class-based reactivity.

<script>
	// Product catalog
	let products = $state([
		{ id: 1, name: 'Laptop', price: 999, image: '💻' },
		{ id: 2, name: 'Headphones', price: 199, image: '🎧' },
		{ id: 3, name: 'Mouse', price: 49, image: '🖱️' },
		{ id: 4, name: 'Keyboard', price: 129, image: '⌨️' }
	])

	// Cart items: { productId, quantity }
	let cartItems = $state([])

	// Derived computations
	let cartWithDetails = $derived(
		cartItems.map((item) => {
			const product = products.find((p) => p.id === item.productId)
			return {
				...item,
				product,
				subtotal: product.price * item.quantity
			}
		})
	)

	let cartTotal = $derived(cartWithDetails.reduce((sum, item) => sum + item.subtotal, 0))

	let cartItemCount = $derived(cartItems.reduce((sum, item) => sum + item.quantity, 0))

	// Actions
	function addToCart(productId) {
		const existing = cartItems.find((item) => item.productId === productId)

		if (existing) {
			existing.quantity++
		} else {
			cartItems.push({ productId, quantity: 1 })
		}
	}

	function removeFromCart(productId) {
		const index = cartItems.findIndex((item) => item.productId === productId)
		if (index !== -1) {
			cartItems.splice(index, 1)
		}
	}

	function updateQuantity(productId, quantity) {
		const item = cartItems.find((item) => item.productId === productId)
		if (item) {
			if (quantity <= 0) {
				removeFromCart(productId)
			} else {
				item.quantity = quantity
			}
		}
	}
</script>

<div class="shop">
	<h2>Products</h2>
	<div class="products">
		{#each products as product}
			<div class="product">
				<span class="emoji">{product.image}</span>
				<h3>{product.name}</h3>
				<p>${product.price}</p>
				<button onclick={() => addToCart(product.id)}> Add to Cart </button>
			</div>
		{/each}
	</div>

	<h2>Cart ({cartItemCount} items)</h2>
	{#if cartItems.length === 0}
		<p>Your cart is empty</p>
	{:else}
		<ul class="cart">
			{#each cartWithDetails as item}
				<li>
					<span>{item.product.image} {item.product.name}</span>
					<input
						type="number"
						value={item.quantity}
						min="0"
						onchange={(e) => updateQuantity(item.productId, +e.target.value)}
					/>
					<span>${item.subtotal}</span>
					<button onclick={() => removeFromCart(item.productId)}>🗑️</button>
				</li>
			{/each}
		</ul>
		<p class="total"><strong>Total: ${cartTotal}</strong></p>
	{/if}
</div>

<style>
	.products {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
		gap: 1rem;
	}

	.product {
		border: 1px solid #ddd;
		padding: 1rem;
		border-radius: 8px;
		text-align: center;
	}

	.emoji {
		font-size: 2rem;
	}

	.cart li {
		display: flex;
		gap: 1rem;
		align-items: center;
		margin-bottom: 0.5rem;
	}

	.cart input {
		width: 60px;
	}
</style>

Key takeaways from this example:

  1. Mutation works: cartItems.push(), item.quantity++, and splice() all trigger updates
  2. Derived chains: cartWithDetails depends on cartItems and products; cartTotal depends on cartWithDetails
  3. Granular updates: Changing one item’s quantity only recalculates what’s needed

2: Reactive Timer Class

This example shows how to properly use $state and $derived within a class.

<script>
	class Stopwatch {
		// Reactive state fields
		milliseconds = $state(0)
		isRunning = $state(false)
		laps = $state([])

		// Private field for the interval
		#intervalId = null

		// Derived values
		formatted = $derived.by(() => {
			const ms = this.milliseconds
			const minutes = Math.floor(ms / 60000)
			const seconds = Math.floor((ms % 60000) / 1000)
			const centiseconds = Math.floor((ms % 1000) / 10)

			return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`
		})

		formattedLaps = $derived(
			this.laps.map((lapMs, index) => {
				const minutes = Math.floor(lapMs / 60000)
				const seconds = Math.floor((lapMs % 60000) / 1000)
				const centiseconds = Math.floor((lapMs % 1000) / 10)

				return {
					number: index + 1,
					time: `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`
				}
			})
		)

		// Arrow functions to preserve 'this' binding
		start = () => {
			if (this.isRunning) return

			this.isRunning = true
			const startTime = Date.now() - this.milliseconds

			this.#intervalId = setInterval(() => {
				this.milliseconds = Date.now() - startTime
			}, 10)
		}

		stop = () => {
			if (!this.isRunning) return

			this.isRunning = false
			clearInterval(this.#intervalId)
		}

		reset = () => {
			this.stop()
			this.milliseconds = 0
			this.laps = []
		}

		lap = () => {
			if (!this.isRunning) return
			this.laps = [...this.laps, this.milliseconds]
		}

		toggle = () => {
			if (this.isRunning) {
				this.stop()
			} else {
				this.start()
			}
		}
	}

	let stopwatch = new Stopwatch()
</script>

<div class="stopwatch">
	<div class="display">{stopwatch.formatted}</div>

	<div class="controls">
		<button onclick={stopwatch.toggle}>
			{stopwatch.isRunning ? '⏸️ Pause' : '▶️ Start'}
		</button>

		<button onclick={stopwatch.lap} disabled={!stopwatch.isRunning}> 🏁 Lap </button>

		<button onclick={stopwatch.reset}> 🔄 Reset </button>
	</div>

	{#if stopwatch.laps.length > 0}
		<div class="laps">
			<h3>Laps</h3>
			<ol>
				{#each stopwatch.formattedLaps as lap}
					<li>Lap {lap.number}: {lap.time}</li>
				{/each}
			</ol>
		</div>
	{/if}
</div>

<style>
	.stopwatch {
		text-align: center;
		font-family: 'Courier New', monospace;
	}

	.display {
		font-size: 4rem;
		margin: 2rem 0;
	}

	.controls {
		display: flex;
		gap: 1rem;
		justify-content: center;
	}

	button {
		padding: 0.75rem 1.5rem;
		font-size: 1rem;
		border: none;
		border-radius: 4px;
		background: #3498db;
		color: white;
		cursor: pointer;
	}

	button:hover {
		background: #2980b9;
	}

	button:disabled {
		background: #bdc3c7;
		cursor: not-allowed;
	}

	.laps {
		margin-top: 2rem;
		text-align: left;
		max-width: 300px;
		margin-left: auto;
		margin-right: auto;
	}

	.laps ol {
		list-style-position: inside;
	}
</style>

Quick Reference

Creating State

// Primitive values
let count = $state(0)
let name = $state('Alice')
let active = $state(true)

// Objects (deeply reactive)
let user = $state({ name: 'Alice', age: 30 })

// Arrays (deeply reactive)
let items = $state([1, 2, 3])

// Raw (shallow reactive)
let data = $state.raw({ large: 'dataset' })

// Snapshot (get plain object)
const plain = $state.snapshot(user)

Using State in Classes

class Counter {
	// Reactive field
	count = $state(0)

	// Arrow function to preserve 'this'
	increment = () => {
		this.count++
	}
}

Shared State Across Modules

// state.svelte.js
export const store = $state({ count: 0 })

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

Key Takeaways

The $state rune is deceptively simple on the surface but powerful underneath:

  1. $state() makes values reactive — Use it for any data that should trigger UI updates when changed.

  2. Deep reactivity is automatic — Objects and arrays become deeply reactive proxies. Mutations at any level trigger updates.

  3. Know when reactivity breaks — Destructuring, passing primitives to functions, and class instances are common gotchas. Access properties through reactive objects.

  4. $state.raw() for performance — Use it for large datasets you replace rather than mutate.

  5. Classes need $state() in fields — Put $state() on the fields that should be reactive, not on the class instance.

  6. Use $state.snapshot() — When you need plain objects for external code or debugging.

With these patterns mastered, you’ll have a solid foundation for building reactive Svelte 5 applications.


See Also

Official Documentation

External Resources