Skip to content

Reactivity: state, derived values and effects

Exercise reactivity-annex.pdf · PDF

Add this button to the NoteBox page from Component syntax. It pushes a note into the array and prints the length:

<button
    onclick={() => {
        notes.push({ id: String(Date.now()), title: 'New note', tag: 'none', pinned: false });
        console.log(notes.length);
    }}
>
    New note
</button>

Click it with the browser console open (F12). The console says 5, 6, 7. The screen still says 4. The data changed. Why didn't the screen?

Nobody told it. notes is a plain array, and Svelte drew it once. In Java Swing you would call repaint(), or remove and re-add the rows, and thousands of bugs live in "I forgot to refresh the label". This page removes that whole job. You describe what the screen looks like for any data, and keeping it up to date is no longer your problem.

What you will learn

  • Explain what reactivity is
  • Declare state with $state, and change it by assigning or by mutating an array
  • Calculate values from state with $derived, and say why you never assign them
  • Use $effect for things outside the page, such as the tab title and localStorage
  • Classify any value of a screen as state, derived or effect
  • The trap: if the result is a value, it is never an effect

The spreadsheet

Open a spreadsheet. Put 3 in A1, 4 in B1, and =A1*B1 in C1. Now change A1 to 10. Who recalculated C1? Did you press a button?

Now type 99 into C1. The formula is gone, and C1 no longer follows anything. That is a bug you just made. We keep this spreadsheet all page.

Reactivity: when state changes, everything that depends on it updates by itself.

Svelte 5 has three reactive keywords for this. They are runes, like the $props you already know.

Rune In the spreadsheet Rule
$state A cell you type a value into (A1, B1) The only thing you ever assign
$derived A cell with a formula (C1) Never assign it. It recalculates itself
$effect The sheet does something outside itself when a cell changes: prints, saves the file For the outside world only
flowchart LR
    S["<b>$state</b><br>notes, query<br>(cells you type in)"] -->|"recalculates"| D["<b>$derived</b><br>visible, pinnedCount<br>(formula cells)"]
    S -->|"updates"| M["<b>Markup</b><br>what is on screen"]
    D -->|"updates"| M
    S -->|"runs"| E["<b>$effect</b><br>tab title, localStorage<br>(outside the page)"]
    D -->|"runs"| E

State

State is data that can change and that the screen depends on.

Change one line. const notes = [ becomes:

let notes = $state([
    // the same four notes
]);

Click the button again. The row appears. That is the whole change.

Now replace the inline handler with a function:

<script>
    // ...
    function addNote() {
        notes.push({ id: String(Date.now()), title: 'New note', tag: 'none', pinned: false });
    }
</script>

<button onclick={addNote}>New note</button>

Each new note gets its own id from the clock. The ids must differ: if you give two notes the same id, the key of {#each} is no longer unique and Svelte stops with the error each_key_duplicate.

The hint {notes.length} notes updates too. Did you write any code to update that paragraph?

What counts as a change? Try these three, one at a time, inside addNote:

You write What happens
notes.push(...) The list updates. State arrays and objects are reactive all the way down
notes[0].title = 'Bread' That one row updates
notes = [] The list updates. Reassigning works too

Careful: not every variable is reactive

Only what you declare with $state, and what derives from it. A plain let count = 0 changes silently, as the dead list showed. That is deliberate: you can see in the code what can change the screen. Also, an old tutorial may tell you to copy the array (notes = [...notes, x]) to trigger an update. That was Svelte 4. In Svelte 5, notes.push(x) is enough.

Derived values

Add a search box. It needs new state, and an input tied to it. bind:value keeps the input and the variable in sync in both directions; you will see more of it in Forms and validation.

<script>
    // ...
    let query = $state('');
</script>

<input type="search" placeholder="Search notes" aria-label="Search notes" bind:value={query} />
<p>You typed: {query}</p>

Type, and the paragraph follows every key. Now delete the paragraph and think. The list on screen: is it notes? Is it a new piece of state? It is a formula: the notes whose title contains the query.

let visible = $derived(
    notes.filter((note) => note.title.toLowerCase().includes(query.toLowerCase()))
);
let pinnedCount = $derived(notes.filter((note) => note.pinned).length);

A derived value is calculated from state and recalculates by itself when that state changes.

The markup now uses visible:

<p class="hint">{visible.length} of {notes.length} notes · {pinnedCount} pinned</p>

{#if visible.length === 0}
    <p>No notes match "{query}".</p>
{:else}
    <ul>
        {#each visible as note (note.id)}
            <NoteRow title={note.title} tag={note.tag} pinned={note.pinned} />
        {/each}
    </ul>
{/if}

Type ma: one note. Type xyz: the message. Clear it and click New note: visible followed notes too. One formula, two inputs. You read visible like any variable. You do not call visible().

Now type over the formula. Add visible = []; as the last line of addNote and click. The list empties, although the notes are still there (the hint says so). Type one letter in the search and the list comes back. You did what you did with the 99 in C1. Svelte allows it for an advanced trick you do not need this term. Remove the line.

You change the state; the derived values follow. Never the other way round.

Careful: no updateVisible()

"I need a function updateVisible(), and I must call it whenever notes or query change." That is the Swing reflex. The formula already is the update. If you find yourself calling an update function, you wanted $derived.

Effects

The browser tab is outside the page's markup. To touch the outside when state changes, use an effect:

$effect(() => {
    document.title = `NoteBox (${notes.length})`;
});

An effect is a function that runs after the page updates, and again whenever the state it reads changes.

Add a note and the tab changes. How does Svelte know this effect depends on notes? It watches which state the function reads while it runs.

Remembering the notes

Reload the page: your new notes are gone. localStorage is a small store in the browser that survives reloads. It only keeps strings, so the array goes through JSON.stringify:

$effect(() => {
    localStorage.setItem('notebox', JSON.stringify(notes));
});

And you read it back when the page starts. Rename the array of four notes to seed, then:

const saved = localStorage.getItem('notebox');

let notes = $state(saved ? JSON.parse(saved) : seed);

Save and reload. The page breaks with a 500 error, and the terminal shows one of these, depending on your version of Node.js:

Node.js The terminal says
22 and 24 localStorage is not defined
26 Cannot read properties of undefined (reading 'getItem')

Who has no localStorage? The server. By default, SvelteKit draws the first version of each page in Node.js, before the browser gets it. This is server-side rendering (SSR). Node.js cannot see the storage of the user's browser. NoteBox will be an installed app (Unit 8), so you switch SSR off, once, for the whole app:

src/routes/+layout.js
export const ssr = false;

Reload. It works, and the notes survive. Check in DevTools: Application, then Local Storage. If your notes ever look corrupted after an experiment, delete the key notebox there.

Two precise points. Effects never run on the server: it was the getItem line at the top of the script that crashed. And turning SSR off is not a hack: it is a page option SvelteKit documents for apps that live only in the browser. For a public website you would keep it on.

Careful: localStorage is not a database

It is a few megabytes of strings in this browser. On another device or in another browser, it is empty. Real data sources come in Consuming data.

When not to use an effect

Do not write this:

// DON'T: an effect that calculates a value
let visible = $state([]);
$effect(() => {
    visible = notes.filter((note) => note.pinned);
});

It works, a moment late, and it is the door to infinite loops. The Svelte docs call $effect an escape hatch, and warn you against exactly this.

If the result is a value, it is $derived. If the result is something happening outside, it is $effect.

The whole page

Here is everything so far, with the style of the new button and the search box:

src/routes/+page.svelte
<script>
    import NoteRow from '#lib/NoteRow.svelte';

    const seed = [
        { id: '1', title: 'Shopping list', tag: 'home', pinned: true },
        { id: '2', title: 'Maths homework', tag: 'school', pinned: false },
        { id: '3', title: 'Passwords', tag: 'none', pinned: false },
        { id: '4', title: 'Ideas for the app', tag: 'school', pinned: false }
    ];

    const saved = localStorage.getItem('notebox');

    let notes = $state(saved ? JSON.parse(saved) : seed);
    let query = $state('');

    let visible = $derived(
        notes.filter((note) => note.title.toLowerCase().includes(query.toLowerCase()))
    );
    let pinnedCount = $derived(notes.filter((note) => note.pinned).length);

    $effect(() => {
        localStorage.setItem('notebox', JSON.stringify(notes));
    });

    $effect(() => {
        document.title = `NoteBox (${notes.length})`;
    });

    function addNote() {
        notes.push({ id: String(Date.now()), title: 'New note', tag: 'none', pinned: false });
    }
</script>

<h1>NoteBox</h1>

<input type="search" placeholder="Search notes" aria-label="Search notes" bind:value={query} />

<p class="hint">{visible.length} of {notes.length} notes · {pinnedCount} pinned</p>

{#if visible.length === 0}
    <p>No notes match "{query}".</p>
{:else}
    <ul>
        {#each visible as note (note.id)}
            <NoteRow title={note.title} tag={note.tag} pinned={note.pinned} />
        {/each}
    </ul>
{/if}

<button onclick={addNote}>New note</button>

<style>
    h1 {
        margin: 0 0 16px;
        font-size: 28px;
    }

    input {
        width: 100%;
        height: 44px;
        padding: 0 12px;
        font: inherit;
        border: 1px solid var(--nb-ink);
        border-radius: var(--nb-radius);
    }

    .hint {
        margin: 8px 0 16px;
        font-size: 13px;
        color: var(--nb-muted);
    }

    ul {
        margin: 0;
        padding: 0;
        list-style: none;
        border-top: 1px solid var(--nb-line);
    }

    button {
        width: 100%;
        height: 44px;
        margin-top: 24px;
        font: inherit;
        color: var(--nb-paper);
        background: var(--nb-ink);
        border: 0;
        border-radius: var(--nb-radius);
        cursor: pointer;
    }
</style>

Which rune?

Take any value on a screen and ask three questions, in this order:

flowchart TB
    Q1{"Does it ever change?"} -->|"no"| C["<b>Constant or prop</b><br>no rune"]
    Q1 -->|"yes"| Q2{"Can a formula on other<br>values calculate it?"}
    Q2 -->|"yes"| D["<b>$derived</b>"]
    Q2 -->|"no"| Q3{"Is it a value, or something<br>happening outside the page?"}
    Q3 -->|"a value"| S["<b>$state</b>"]
    Q3 -->|"something happening"| E["<b>$effect</b>"]

Try it on a cinema booking app:

Value Rune Why
The seats you have chosen $state Only you can decide them. No formula knows
The total price $derived Seats times price per seat
Whether the Pay button makes sense (at least one seat) $derived It changes, but nobody sets it: it is a formula on the seats
The name of the film Constant or prop It does not change on this screen
Saving your seats so a reload does not lose them $effect Something happening outside the page

The one people argue about is the third. It feels like state because it changes. But nobody ever sets it.

Your turn: only pinned

The handout at the top of this page asks you to add an "Only pinned" checkbox, in one single formula with the search, plus the right empty message and a tab title that follows. If you get stuck:

You see Why
A second derived and an {#if} choosing between two lists It works, but imagine a third filter. One formula with && grows; pairs of lists do not
An effect that assigns visible That is the "don't" above. Use $derived
JSON.parse fails on reload You saved another value under the same localStorage key as the notes. One key per thing
The box ticks but the list does not change You wrote checked={onlyPinned} without bind:
The tab title never changes The effect reads nothing reactive (a typo, or a string typed by hand)

Then make the main list of your own app live: at least two pieces of $state, two $derived (one of them a count on screen) and one $effect. The Svelte tutorial, part Basic Svelte, has a Reactivity section for extra practice.

Key takeaways

  • Reactivity: when state changes, everything that depends on it updates by itself. You never redraw by hand.
  • $state is a cell you type into: the only thing you assign. Arrays and objects are reactive all the way down.
  • $derived is a formula cell: never assign it, and read it like a variable.
  • $effect is for the outside world: the tab title, localStorage, timers. It runs after the page updates, when the state it reads changes.
  • If the result is a value, it is $derived. If it is something happening outside, it is $effect.
  • SSR draws pages in Node.js first, which has no access to the browser's storage. export const ssr = false turns it off for an app that lives in the browser.

Quick check

1. A plain let count = 0 is changed by a button, and the markup shows {count}. What does the user see?
  • The old number, because a plain variable does not tell the screen
  • The new number, because every variable in Svelte is reactive
  • The new number, but only after a short delay
  • An error, because plain variables cannot be changed

Only what you declare with $state, and what derives from it, is reactive. A plain variable changes silently.

2. notes is declared with $state. Which line makes the list on screen update?
  • notes.push(newNote);
  • notes.update(newNote);
  • notes.refresh(newNote);
  • repaint(notes, newNote);

$state arrays are reactive all the way down, so mutating one is enough. There is no redraw function for you to call.

3. What happens to a $derived value when the state it reads changes?
  • It recalculates by itself
  • It keeps its first value until the page reloads
  • It waits until you call it like a function
  • It waits until you assign it a new value

A derived value behaves like a spreadsheet formula. You read it like any variable and never assign it.

4. You need the number of pinned notes shown on screen. Which code is right?
  • let pinned = $derived(notes.filter((n) => n.pinned).length);
  • let pinned = $state(notes.filter((n) => n.pinned).length);
  • $effect(() => { pinned = notes.filter((n) => n.pinned).length; });
  • const pinned = notes.filter((n) => n.pinned).length;

The result is a value, calculated from state, so it is $derived. With $state or a plain const it would never follow the notes, and an effect would calculate it late and open the door to infinite loops.

5. Which of these is a correct use of $effect?
  • Changing the browser tab's title when the number of notes changes
  • Calculating the filtered list that the page shows on screen
  • Calculating the total price from the items in a cart
  • Turning a title into capital letters to show it in a heading

$effect is for the outside world. The other three produce values to show, so they are $derived or a plain expression in the markup.

6. Your script calls localStorage.getItem(...) at the top, and the page gives a 500 error. Why?
  • SvelteKit first draws the page in Node.js, which cannot see the browser's storage
  • The browser blocks localStorage on any address that starts with localhost
  • localStorage can only be read inside an $effect, never anywhere else
  • The stored text has to be converted with JSON.parse before you can read it

This is server-side rendering: by default the first draw happens on the server. For an app that lives only in the browser, export const ssr = false in src/routes/+layout.js switches it off.

7. A quiz app shows the current question, the score, and a “Finished” message after the last question. It also sends the final score to a server. Which classification is right?
  • Current question number is state, the score and "finished" are derived, sending the score is an effect
  • Current question number is derived, the score and "finished" are state, sending the score is derived
  • Current question number is an effect, the score and "finished" are effects, sending the score is state
  • Current question number is state, the score and "finished" are effects, sending the score is state

Only the player's progress is really typed in, so it is $state. The score and "finished" can be calculated from the answers and the question number, so they are $derived. Talking to a server is something happening outside the page, so it is an $effect.

Your state is ready to be changed, and so far only one button changes it. Next comes the user: clicks, keys, and an Undo: Events and actions.