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:
let count = $state(0)- Creates a reactive variable calledcountwith an initial value of0onclick={() => count++}- When clicked, incrementcountby 1{count}- Display the current value ofcountin the button text
What happens when you click:
- The click handler runs
count++ - Svelte detects that
countchanged - 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:
| Location | Example |
|---|---|
Component <script> blocks | let name = $state('Alice') |
.svelte.js / .svelte.ts files | For shared reactive state |
| Class fields | class Counter { count = $state(0) } |
You cannot use $state in:
- Regular
.jsor.tsfiles (compiler doesn’t process them) - Outside of the
<script>block in a.sveltefile
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:
- You write to
user.address.city - The Proxy intercepts this write operation
- Svelte records that
user.address.citychanged - Svelte finds all UI elements that use
user.address.city - 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:
- Array methods work:
todos.push()triggers an update. So dopop(),splice(),shift(), etc. - Nested objects are reactive: Changing
todo.doneupdates just that checkbox - New items become reactive: When you push a new object, it automatically becomes reactive too
- 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 TipDeep 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 URLSvelte ships
SvelteMap,SvelteSet,SvelteDate, andSvelteURLinsvelte/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 DestructuringDestructuring a
$stateobject 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:
- Performance (see later section)
$state.raw(only reassignment works)- 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:
xis evaluated to1yis evaluated to2- The function receives the values
1and2 - It returns
3 totalis set to3
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 DirectlyExporting a
$stateprimitive 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:
Deep tracking (
$state): You have a real-time sensor on every single book. You know the instant any book is moved, opened, or touched.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.rawUse
$state.rawfor 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 properties | You always replace the entire value |
| Data is user-interactive | Data comes from an API |
| Small to medium-sized data | Large datasets (1000+ items) |
| You need granular updates | You 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:
- Mutation works:
cartItems.push(),item.quantity++, andsplice()all trigger updates - Derived chains:
cartWithDetailsdepends oncartItemsandproducts;cartTotaldepends oncartWithDetails - 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:
$state()makes values reactive — Use it for any data that should trigger UI updates when changed.Deep reactivity is automatic — Objects and arrays become deeply reactive proxies. Mutations at any level trigger updates.
Know when reactivity breaks — Destructuring, passing primitives to functions, and class instances are common gotchas. Access properties through reactive objects.
$state.raw()for performance — Use it for large datasets you replace rather than mutate.Classes need
$state()in fields — Put$state()on the fields that should be reactive, not on the class instance.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
- Svelte 5: $state - Official reference
- Runes Tutorial - Interactive tutorial
- Svelte REPL - Try code online
- Migration Guide - Svelte 4 to 5
External Resources
- MDN: JavaScript Proxy - Understanding Proxies
- Svelte Society - Community resources
- Svelte Discord - Get help