17.3 Reactivity Principles
Reactivity is the heart of Svelte. When state changes, the DOM updates automatically. Svelte 5 introduces the Runes system, making reactivity more explicit and powerful.
Svelte 4 Reactivity (Legacy)
In Svelte 4, reactivity worked through simple variable assignment.
<!-- Svelte 4 -->
<script>
let count = 0; // Reactive just by declaring it
$: doubled = count * 2; // Reactive declaration ($:)
$: {
// Reactive block - runs when count changes
console.log(`count changed to ${count}`);
}
function increment() {
count++; // Assignment triggers reactivity
}
</script>
<p>Count: {count}, Doubled: {doubled}</p>
<button on:click={increment}>+1</button>
Svelte 4 Limitations
- Unclear whether a
letvariable is reactive or not (non-reactive outside components) - The
$:syntax is not standard JavaScript — unintuitive - Array/object mutations sometimes required reassignment to trigger updates
- Could not be used in
.jsfiles
Svelte 5 Runes Introduced
Runes are special function-like symbols introduced in Svelte 5. They start with $ and are handled specially by the compiler.
<!-- Svelte 5 Runes -->
<script>
let count = $state(0); // Explicit reactive state
let doubled = $derived(count * 2); // Explicit derived value
$effect(() => {
console.log(`count changed to ${count}`);
});
</script>
<p>Count: {count}, Doubled: {doubled}</p>
<button onclick={() => count++}>+1</button>
$state() — Declaring Reactive State
$state() is the most fundamental Rune, used to declare reactive state.
Basic Usage
<script>
// Primitive types
let count = $state(0);
let name = $state('Alice');
let isActive = $state(false);
// Object (deep reactivity)
let user = $state({
name: 'Alice',
age: 30,
address: {
city: 'New York',
district: 'Manhattan',
},
});
// Array (deep reactivity)
let items = $state(['apple', 'banana', 'cherry']);
</script>
<!-- Object properties can be mutated directly -->
<button onclick={() => user.age++}>Increase Age</button>
<button onclick={() => user.address.city = 'Boston'}>Change City</button>
<button onclick={() => items.push('grape')}>Add Item</button>
Deep Reactivity
Objects and arrays declared with $state() detect nested property changes automatically.
<script>
let todo = $state({
text: 'Grocery shopping',
done: false,
subtasks: [
{ text: 'Buy milk', done: false },
{ text: 'Buy bread', done: false },
],
});
// Nested property change → automatic update
function toggleSubtask(index) {
todo.subtasks[index].done = !todo.subtasks[index].done; // Works!
}
</script>
Using with Classes
<script>
class Counter {
count = $state(0);
step = $state(1);
get doubled() {
return $derived(this.count * 2);
}
increment() {
this.count += this.step;
}
reset() {
this.count = 0;
}
}
const counter = new Counter();
</script>
<p>Count: {counter.count}</p>
<button onclick={() => counter.increment()}>+{counter.step}</button>
$derived() — Derived Values
$derived() declares a value computed from other reactive values. It recalculates automatically when its dependencies change.
Basic Usage
<script>
let price = $state(100);
let quantity = $state(3);
let discountRate = $state(0.1);
// Simple derived value
let total = $derived(price * quantity);
// Derived from multiple values
let discountedTotal = $derived(total * (1 - discountRate));
// Derived with formatting
let formattedTotal = $derived(
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
.format(discountedTotal)
);
</script>
<p>Unit price: ${price}</p>
<p>Quantity: {quantity}</p>
<p>Subtotal: ${total}</p>
<p>After discount: {formattedTotal}</p>
$derived.by() — Complex Derived Logic
For multi-line logic:
<script>
let numbers = $state([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
let threshold = $state(5);
let analysis = $derived.by(() => {
const filtered = numbers.filter(n => n > threshold);
const sum = filtered.reduce((a, b) => a + b, 0);
const avg = filtered.length ? sum / filtered.length : 0;
return { filtered, sum, avg: avg.toFixed(2) };
});
</script>
<input type="range" min="0" max="10" bind:value={threshold} />
<p>Threshold: {threshold}</p>
<p>Filtered values: {analysis.filtered.join(', ')}</p>
<p>Sum: {analysis.sum}, Average: {analysis.avg}</p>
$effect() — Side Effects
$effect() defines side effects that run when reactive state changes. Similar to React's useEffect, but automatically tracks dependencies without a dependency array.
Basic Usage
<script>
let count = $state(0);
let title = $state('Svelte App');
// Runs whenever count or title changes
$effect(() => {
document.title = `${title} - Count: ${count}`;
});
</script>
Cleanup Function
<script>
let enabled = $state(false);
let position = $state({ x: 0, y: 0 });
$effect(() => {
if (!enabled) return;
function handleMouseMove(e) {
position.x = e.clientX;
position.y = e.clientY;
}
window.addEventListener('mousemove', handleMouseMove);
// Cleanup: called before effect re-runs or component unmounts
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
});
</script>
<label>
<input type="checkbox" bind:checked={enabled} />
Enable mouse tracking
</label>
{#if enabled}
<p>Position: ({position.x}, {position.y})</p>
{/if}
$effect.pre() — Run Before DOM Update
<script>
let messages = $state([]);
let chatContainer;
// Runs before DOM update
$effect.pre(() => {
messages; // Track dependency
});
// Runs after DOM update — handle scroll
$effect(() => {
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
</script>
$state.snapshot() — Snapshots
Converts a reactive object to a plain JavaScript object (useful for serialization, JSON transmission).
<script>
let form = $state({
username: '',
email: '',
password: '',
});
async function handleSubmit() {
// Send a plain object instead of a reactive proxy
const data = $state.snapshot(form);
console.log(data); // { username: '...', email: '...', password: '...' }
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
</script>
Array/Object Reactivity
Array Mutations
<script>
let items = $state(['apple', 'banana']);
// All of these trigger reactivity
function examples() {
items.push('cherry'); // Add
items.pop(); // Remove
items.splice(1, 1, 'strawberry'); // Replace
items[0] = 'watermelon'; // Direct index assignment
items = [...items, 'grape']; // Reassignment also works
}
</script>
Svelte 4 vs Svelte 5 Array Updates
<!-- Svelte 4: reassignment required -->
<script>
let items = ['apple', 'banana'];
function addItem() {
items.push('cherry');
items = items; // Forced reassignment to trigger reactivity
}
</script>
<!-- Svelte 5: no reassignment needed -->
<script>
let items = $state(['apple', 'banana']);
function addItem() {
items.push('cherry'); // Reactivity triggered automatically
}
</script>
Using Runes in .svelte.js Files
Runes can be used outside .svelte files with the .svelte.js or .svelte.ts extension.
// src/lib/counter.svelte.js
export class Counter {
#count = $state(0);
#step;
constructor(step = 1) {
this.#step = step;
}
get count() {
return this.#count;
}
increment() {
this.#count += this.#step;
}
reset() {
this.#count = 0;
}
}
<!-- Using in a component -->
<script>
import { Counter } from '$lib/counter.svelte.js';
const counter = new Counter(2);
</script>
<button onclick={() => counter.increment()}>Count: {counter.count}</button>
Practical Example 1: Reactive Counter
<!-- ReactiveCounter.svelte -->
<script>
let count = $state(0);
let step = $state(1);
let history = $state([]);
let isNegative = $derived(count < 0);
let absCount = $derived(Math.abs(count));
let historyText = $derived(
history.slice(-5).reverse().join(' → ') || 'none'
);
function change(delta) {
history.push(count);
count += delta;
}
function reset() {
history.push(count);
count = 0;
}
</script>
<div class="counter" class:negative={isNegative}>
<h2 class:text-red={isNegative}>
{isNegative ? '-' : ''}{absCount}
</h2>
<div class="controls">
<button onclick={() => change(-step)}>-{step}</button>
<input type="number" bind:value={step} min="1" max="10" />
<button onclick={() => change(step)}>+{step}</button>
</div>
<button onclick={reset}>Reset</button>
<p class="history">Recent: {historyText}</p>
</div>
<style>
.counter {
text-align: center;
padding: 2rem;
border: 2px solid #4caf50;
border-radius: 12px;
max-width: 300px;
margin: 0 auto;
}
.counter.negative { border-color: #f44336; }
h2 { font-size: 3rem; margin: 0; }
.text-red { color: #f44336; }
.controls {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin: 1rem 0;
}
input[type="number"] { width: 60px; text-align: center; padding: 0.25rem; }
.history { font-size: 0.85rem; color: #666; }
</style>
Practical Example 2: Reactive Shopping Cart
<!-- ShoppingCart.svelte -->
<script>
const PRODUCTS = [
{ id: 1, name: 'Laptop', price: 1200 },
{ id: 2, name: 'Mouse', price: 35 },
{ id: 3, name: 'Keyboard', price: 89 },
{ id: 4, name: 'Monitor', price: 350 },
];
let cart = $state([]);
let discountCode = $state('');
const DISCOUNT_CODES = { 'SVELTE10': 0.10, 'SAVE20': 0.20 };
let validDiscount = $derived(DISCOUNT_CODES[discountCode] ?? 0);
let cartTotal = $derived(
cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
let discountAmount = $derived(cartTotal * validDiscount);
let finalTotal = $derived(cartTotal - discountAmount);
let itemCount = $derived(cart.reduce((sum, item) => sum + item.quantity, 0));
$effect(() => {
localStorage.setItem('cart', JSON.stringify($state.snapshot(cart)));
});
function addToCart(product) {
const existing = cart.find(item => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
cart.push({ ...product, quantity: 1 });
}
}
function removeFromCart(id) {
const index = cart.findIndex(item => item.id === id);
if (index !== -1) cart.splice(index, 1);
}
function updateQuantity(id, quantity) {
const item = cart.find(item => item.id === id);
if (item) {
if (quantity <= 0) removeFromCart(id);
else item.quantity = quantity;
}
}
const formatPrice = (n) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
</script>
<div class="shop">
<section class="products">
<h2>Products</h2>
{#each PRODUCTS as product}
<div class="product-card">
<span>{product.name}</span>
<span>{formatPrice(product.price)}</span>
<button onclick={() => addToCart(product)}>Add to Cart</button>
</div>
{/each}
</section>
<section class="cart">
<h2>Cart ({itemCount} items)</h2>
{#if cart.length === 0}
<p>Your cart is empty.</p>
{:else}
{#each cart as item (item.id)}
<div class="cart-item">
<span>{item.name}</span>
<div class="qty-control">
<button onclick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onclick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
<span>{formatPrice(item.price * item.quantity)}</span>
<button onclick={() => removeFromCart(item.id)}>×</button>
</div>
{/each}
<div class="discount">
<input bind:value={discountCode} placeholder="Enter discount code" />
{#if validDiscount > 0}
<span class="valid">✓ {validDiscount * 100}% discount applied!</span>
{/if}
</div>
<div class="totals">
<p>Subtotal: {formatPrice(cartTotal)}</p>
{#if discountAmount > 0}
<p class="discount-line">Discount: -{formatPrice(discountAmount)}</p>
{/if}
<p class="final"><strong>Total: {formatPrice(finalTotal)}</strong></p>
</div>
{/if}
</section>
</div>
Svelte 4 vs Svelte 5 Comparison Table
| Feature | Svelte 4 | Svelte 5 |
|---|---|---|
| Reactive variable | let x = 0 | let x = $state(0) |
| Derived value | $: y = x * 2 | let y = $derived(x * 2) |
| Side effects | $: { console.log(x) } | $effect(() => { console.log(x) }) |
| Props | export let name | let { name } = $props() |
| Event handler | on:click={handler} | onclick={handler} |
| Array reactivity | push(); arr = arr; | push() directly |
| External files | Not supported | Works in .svelte.js |
Pro Tips
Tip 1: Avoid Overusing $effect
<!-- Bad: using $effect to compute derived values -->
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => {
doubled = count * 2; // Not recommended
});
</script>
<!-- Good: use $derived -->
<script>
let count = $state(0);
let doubled = $derived(count * 2); // Recommended
</script>
Tip 2: State Initialization Patterns
<script>
// When initial value requires a function
let items = $state(loadFromStorage());
function loadFromStorage() {
try {
return JSON.parse(localStorage.getItem('items') ?? '[]');
} catch {
return [];
}
}
</script>
Tip 3: Debugging Reactivity
<script>
let count = $state(0);
$effect(() => {
// Track reactivity during development
console.log('[effect] count =', count);
});
</script>
Summary
| Rune | Role | When to Use |
|---|---|---|
$state() | Declare reactive state | Mutable values |
$derived() | Compute derived value | Values calculated from other state |
$derived.by() | Complex derived logic | Multi-line calculations |
$effect() | Side effects | Non-DOM work, API calls, etc. |
$state.snapshot() | Create snapshot | Serialization, JSON transfer |
The next chapter covers Svelte's template syntax — logic blocks, bindings, and event handling.