Skip to main content
Advertisement

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 let variable 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 .js files

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

FeatureSvelte 4Svelte 5
Reactive variablelet x = 0let x = $state(0)
Derived value$: y = x * 2let y = $derived(x * 2)
Side effects$: { console.log(x) }$effect(() => { console.log(x) })
Propsexport let namelet { name } = $props()
Event handleron:click={handler}onclick={handler}
Array reactivitypush(); arr = arr;push() directly
External filesNot supportedWorks 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

RuneRoleWhen to Use
$state()Declare reactive stateMutable values
$derived()Compute derived valueValues calculated from other state
$derived.by()Complex derived logicMulti-line calculations
$effect()Side effectsNon-DOM work, API calls, etc.
$state.snapshot()Create snapshotSerialization, JSON transfer

The next chapter covers Svelte's template syntax — logic blocks, bindings, and event handling.

Advertisement