Introduction
Svelte's reactivity system is one of the language's defining features, and understanding $state deeply is essential for writing predictable, performant applications. In this guide, we'll explore $state in depth: a Svelte & SvelteKit guide for developers, covering everything from basic primitives to complex nested objects and the performance-oriented utilities that let you optimize when it matters most.
If you've worked with reactive frameworks before, you might expect reactivity to be opt-in or require wrapping. Svelte is different—$state makes reactivity explicit and straightforward, whether you're managing a counter or a deeply nested data structure from your backend API.
Primitives and objects are reactive
At its core, $state transforms a variable into a reactive value. When you declare a variable with $state, Svelte tracks changes to that variable and automatically updates any component that depends on it. This works for primitives—numbers, strings, booleans—just as easily as for objects.
Let's start with a simple example:
<script>
let count = $state(0);
let username = $state('alice');
let isActive = $state(false);
function increment() {
count++;
}
function setUsername(name) {
username = name;
}
</script>
<button onclick={increment}>
Clicks: {count}
</button>
<input
type="text"
value={username}
onchange={(e) => setUsername(e.target.value)}
/>
<p>Active: {isActive}</p>Each time you modify count, username, or isActive, Svelte detects the change and re-renders the relevant parts of your component. No special methods, no `.set()` calls, no async updates—just assignment.
For objects, the same principle applies. Svelte wraps the object internally and tracks changes to its properties:
<script>
let user = $state({
id: 1,
name: 'Bob',
email: 'bob@example.com',
settings: {
theme: 'dark',
notifications: true
}
});
function updateName(newName) {
user.name = newName;
}
function toggleNotifications() {
user.settings.notifications = !user.settings.notifications;
}
</script>
<p>{user.name} ({user.email})</p>
<p>Theme: {user.settings.theme}</p>
<button onclick={toggleNotifications}>
Notifications: {user.settings.notifications ? 'on' : 'off'}
</button>Notice that we're updating nested properties directly. Svelte's reactivity system handles the detection automatically—you don't need to reassign the entire object or use immutability patterns unless you want to.
Deep reactivity for arrays and objects
One of Svelte's strengths is that reactivity goes deep by default. When you modify properties on nested objects or items within arrays, Svelte recognizes those changes and updates the UI. This is especially useful when working with real-world data structures from APIs or databases.
Consider a typical e-commerce scenario where you're managing a shopping cart:
<script>
let cart = $state({
items: [
{ id: 1, name: 'Laptop', quantity: 1, price: 999 },
{ id: 2, name: 'Mouse', quantity: 2, price: 25 }
],
coupon: null
});
function incrementQuantity(itemId) {
const item = cart.items.find(i => i.id === itemId);
if (item) {
item.quantity++;
}
}
function removeItem(itemId) {
cart.items = cart.items.filter(i => i.id !== itemId);
}
function applyCoupon(code) {
cart.coupon = code;
}
</script>
<ul>
{#each cart.items as item (item.id)}
<li>
{item.name}: {item.quantity} × ${item.price}
<button onclick={() => incrementQuantity(item.id)}>+</button>
<button onclick={() => removeItem(item.id)}>Remove</button>
</li>
{/each}
</ul>
<p>Coupon: {cart.coupon || 'None'}</p>
<button onclick={() => applyCoupon('SAVE10')}>Apply coupon</button>When you call incrementQuantity(), Svelte detects the mutation to item.quantity and updates the rendered quantity in the UI. Similarly, reassigning cart.items to filter out a removed item triggers a re-render of the list. Deep reactivity means you can work with nested, complex data structures intuitively.
Arrays are particularly well-supported. Mutations like push, splice, and direct index assignment all trigger reactivity:
<script>
let todos = $state([]);
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false });
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) {
todo.done = !todo.done;
}
}
function clearCompleted() {
todos = todos.filter(t => !t.done);
}
</script>
{#each todos as todo (todo.id)}
<input
type="checkbox"
checked={todo.done}
onchange={() => toggleTodo(todo.id)}
/>
<span class:done={todo.done}>{todo.text}</span>
{/each}The key insight is that Svelte's deep reactivity works best when you're thinking about your data as a single source of truth. Mutations are detected, arrays are properly tracked, and nested properties are reactive without extra configuration.
$state.raw for large immutable data
Sometimes you're working with large data structures that are expensive to wrap or that you intentionally want to keep immutable. For example, you might be storing a snapshot of a database table, reference data from an API, or a complex configuration object that rarely changes. In these cases, $state.raw lets you skip reactivity tracking entirely.
$state.raw creates a reactive variable that holds a non-reactive value. Changes to the object itself won't trigger updates—you have to reassign the entire variable. This is perfect for data that you fetch once and rarely mutate:
<script>
let staticConfig = $state.raw({
apiEndpoint: 'https://api.example.com',
maxRetries: 5,
timeout: 30000,
permissions: {
admin: ['read', 'write', 'delete'],
user: ['read'],
guest: []
}
});
let userRoles = $state.raw(['admin', 'user']);
async function loadSettings() {
const response = await fetch('/api/settings');
staticConfig = await response.json();
}
</script>
<p>API Endpoint: {staticConfig.apiEndpoint}</p>
<p>Max retries: {staticConfig.maxRetries}</p>
<button onclick={loadSettings}>Reload settings</button>The benefit here is memory efficiency and performance. If you have a large data structure that you never mutate internally, wrapping it in $state.raw avoids the overhead of reactivity tracking. The trade-off is that mutations to nested properties won't be reactive—you must reassign the root variable for changes to be detected.
In a SvelteKit load function or page component, this pattern works well for server data that arrives as a snapshot:
// +page.svelte
<script>
import { page } from '$app/stores';
let productCatalog = $state.raw([]);
$effect(() => {
productCatalog = $page.data.products;
});
</script>
{#each productCatalog as product (product.id)}
<div>{product.name}: ${product.price}</div>
{/each}Use $state.raw when you're confident that data is immutable or will only be updated via full reassignment, not nested mutations.
$state.snapshot for plain copies
Sometimes you need to extract a plain JavaScript object from a reactive state variable for logging, comparisons, or sending data to an external system. $state.snapshot takes a reactive value and returns a plain, non-reactive copy of it at that moment in time.
This is useful when you want to:
- Log state without keeping a reference to reactive proxies
- Serialize state to JSON or a database
- Compare state values without reactivity getting in the way
- Pass state to external libraries that don't expect Svelte reactivity
Here's a practical example from a form handling scenario:
<script>
let formData = $state({
email: '',
password: '',
rememberMe: false
});
async function submitForm() {
// Create a snapshot before sending
const payload = $state.snapshot(formData);
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// Log the snapshot, not the reactive object
console.log('Submitted:', payload);
if (response.ok) {
// Reset form
formData = { email: '', password: '', rememberMe: false };
}
} catch (error) {
console.error('Submission failed:', error);
}
}
</script>
<form onsubmit|preventDefault={submitForm}>
<input
type="email"
bind:value={formData.email}
placeholder="Email"
/>
<input
type="password"
bind:value={formData.password}
placeholder="Password"
/>
<label>
<input
type="checkbox"
bind:checked={formData.rememberMe}
/>
Remember me
</label>
<button type="submit">Login</button>
</form>Without $state.snapshot, you'd be serializing a proxy object, which can cause issues with some JSON stringification scenarios or external APIs. The snapshot guarantees a clean, plain object.
Another scenario is tracking changes over time:
<script>
let document = $state({
title: 'Untitled',
content: '',
lastModified: null
});
let history = $state([]);
function saveVersion() {
const snapshot = $state.snapshot(document);
history.push({
...snapshot,
savedAt: new Date().toISOString()
});
}
</script>Each snapshot is independent and won't be affected by future mutations to the original reactive state.
Common mistakes
Forgetting that mutations to nested properties are reactive. New developers sometimes wrap objects in $state but then expect to reassign nested properties explicitly. In Svelte, obj.prop = value is reactive by default. You only need to reassign the root variable if you want to replace it entirely.
Using $state.raw when you need reactivity. If you store data in $state.raw and then mutate it, those mutations won't trigger updates. The component won't re-render. Reserve $state.raw for data you know is immutable or will be updated via full reassignment.
Mutating snapshots expecting them to be reactive. $state.snapshot returns a plain object. Mutations to it won't affect your reactive state and won't be tracked. It's a one-time copy, useful for serialization or external APIs, not for continued use in your component.
Mixing reactive and non-reactive references. In SvelteKit stores or shared modules, be consistent: either wrap data in $state and mutate reactively, or use plain objects and embrace immutability. Mixing patterns in the same codebase makes state harder to reason about.
Key takeaways
$statemakes variables reactive; primitives and objects both work, with changes detected automatically.- Deep reactivity is enabled by default—nested property mutations and array operations trigger updates without reassignment.
$state.rawskips reactivity tracking for large, immutable data structures, improving performance when mutations aren't needed.$state.snapshotextracts a plain copy of reactive state, essential for serialization, logging, and external APIs.- Choose the right tool for your data: reactive state for UI-driven changes, raw for static configuration, and snapshots for data export.






Comments
Loading comments…