Events and actions: every control does something¶
Exercise events-actions-annex.pdf · PDF
Open your Figma prototype and click a button. It "works": it jumps to another picture. Now click anywhere else. Nothing. How many clicks could that prototype answer? Only the ones somebody wired by hand, one arrow at a time.
An interface is a set of promises. Every button promises something. A real app has to keep those promises for every user, every time, with whatever data is there. In this page you keep them.
What you will learn
- Write an event-action table for a screen: where, which event, which action
- Attach a handler to an element with
onclickoronkeydown, with and without arguments - Use the event object:
event.keyandevent.preventDefault() - Send an event up from a component to its parent with a callback prop
- Listen to the keyboard on the whole window with
<svelte:window> - Build delete with Undo instead of a confirmation dialog
- The trap:
onclick={save()}andonclick={save}are not the same
The doorbell¶
Think of a doorbell. The button outside is the event: someone pressed it. The chime inside is the action. The wire between them is the handler. A button without a wire is decoration. A wire to the wrong chime is a bug. We keep this doorbell all page.
An event is something that happens, usually something the user does: a click, a key, a submit. An action is what the app does in answer. A handler is the function that connects them.
"Usually", because a timer ending or data arriving are events too. Something happened, and the app must answer.
The contract¶
Before any code, write the promises down. An event-action table lists them: where, which event, which action. Here is the one for the NoteBox list:
| Where | Event | Action |
|---|---|---|
| Title of a row | click | Select that note |
| Delete of a row | click | Remove the note, remember it, show the Undo bar |
| Undo button | click | Put the note back where it was |
| Window | keydown, Ctrl+Z | Same as Undo, if there is something to undo |
| Window | keydown, Escape | Deselect |
| (time) | 5 s after a delete | Hide the bar, forget the note |
Delete with no "Are you sure?". Why? In Apple's guidelines you met forgiveness: reversible actions, not a barrage of alerts. A dialog gets answered without being read. Undo forgives the mistake after the user notices it.
Click, with an argument¶
A row now does two things, so it holds two real <button> elements. It receives the whole note, whether it is selected, and two functions:
<script>
let { note, selected = false, onselect, ondelete } = $props();
</script>
<li class={['row', selected && 'selected']}>
<button class="open" onclick={() => onselect(note.id)}>
{note.title}
</button>
<button class="delete" aria-label="Delete {note.title}" onclick={() => ondelete(note.id)}>
Delete
</button>
</li>
<style>
.row {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 8px;
border-bottom: 1px solid var(--nb-line);
}
.selected {
background: var(--nb-surface);
}
button {
height: 44px;
font: inherit;
background: none;
border: none;
cursor: pointer;
}
.open {
flex: 1;
font-weight: 500;
text-align: left;
}
.delete {
color: var(--nb-danger);
}
</style>
If you added a body and a badge to your row, keep those lines. This version is minimal so the events stand out.
The page owns the state, and it tells each row what to call:
<script>
// ...
let selectedId = $state(null);
function select(id) {
selectedId = id;
}
</script>
<!-- inside the {#each} -->
<NoteRow {note} selected={note.id === selectedId} onselect={select} ondelete={remove} />
{note} is short for note={note}. Click a title and the row turns grey.
Look at onclick={() => onselect(note.id)}. That is an arrow function: a small function with no name. It says "when clicked, then call this".
Careful: the braces need a function, not a call
onclick={onselect(note.id)} looks almost the same, and it is wrong. onselect(note.id) is not a function: it is a call, and its result is what you hand to the button. Svelte works it out when the click arrives. So the page loads looking fine. Then the first click runs your function, Svelte stops with the error state_unsafe_mutation if that function changes state, and the page ends in 500 Internal Error.
With an argument, wrap it: onclick={() => remove(note.id)}. With no argument, name the function and add no brackets: onclick={undo}.
Now notice what the row does not know. It has no idea what "select" means. It rings the bell; the page decides.
A callback prop is a function a parent passes to a child, so the child can announce that something happened.
flowchart TB
P["<b>+page.svelte</b><br>owns the notes"] -->|"props go down<br>note, selected, onselect, ondelete"| R["<b>NoteRow</b><br>rings the bell"]
R -->|"events go up<br>onselect(note.id)"| P
Props down, events up. Could the child change the object itself? It has it, after all. It could, and then nobody knows who changed what. Children announce; the owner of the state acts.
Careful: on:click is old
on:click was Svelte 4. In Svelte 5, events are ordinary attributes: onclick, lower case, no colon. Half the tutorials on the internet are older than October 2024, so check the date.
Delete and Undo¶
Add this to the page:
let lastDeleted = $state(null);
function remove(id) {
const index = notes.findIndex((note) => note.id === id);
const [note] = notes.splice(index, 1);
lastDeleted = { note, index };
if (selectedId === id) selectedId = null;
}
function undo() {
if (!lastDeleted) return;
notes.splice(lastDeleted.index, 0, lastDeleted.note);
lastDeleted = null;
}
splice(index, 1) cuts one item out and returns it. splice(index, 0, note) puts it back in the same place. Read each function against its line of the contract.
Under the list, show the bar:
{#if lastDeleted}
<div class="undo-bar" role="status">
<span>{lastDeleted.note.title} deleted</span>
<button onclick={undo}>Undo</button>
</div>
{/if}
Delete a note and undo it. Reload: the effect from Reactivity saved the deletion too, for free.
role="status" makes a screen reader announce the bar when it appears. And why {undo} here, without an arrow function? There is no argument to pass, so the function itself is the handler.
Keys¶
Keys do not belong to one element, so listen on the window. <svelte:window> is a special element for that. Put it right after the <script>:
<script>
// ...
function onKey(event) {
if (event.key === 'Escape') {
selectedId = null;
}
if (event.ctrlKey && event.key === 'z' && lastDeleted) {
event.preventDefault();
undo();
}
}
</script>
<svelte:window onkeydown={onKey} />
Every handler receives the event object, which describes what happened.
event.keyis the key that was pressed.event.preventDefault()cancels what the browser would do by itself.
Here the browser would undo your last typing in a text field. You only cancel that when you really have something to undo. On a Mac the key is Cmd+Z: use (event.ctrlKey || event.metaKey) if you have one.
The timer¶
The last line of the contract is not a user event: time passes. That is something outside the page, so it is an effect.
$effect(() => {
if (!lastDeleted) return;
const timer = setTimeout(() => {
lastDeleted = null;
}, 5000);
return () => clearTimeout(timer);
});
The function an effect returns is its clean-up: it runs before the effect runs again. Delete two notes quickly. The first timer is cancelled, and the bar stays five full seconds for the second.
The whole page¶
Here is everything so far. It keeps the search, the saving and the New note button from Reactivity:
<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 selectedId = $state(null);
let lastDeleted = $state(null);
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})`;
});
$effect(() => {
if (!lastDeleted) return;
const timer = setTimeout(() => {
lastDeleted = null;
}, 5000);
return () => clearTimeout(timer);
});
function addNote() {
notes.push({ id: String(Date.now()), title: 'New note', tag: 'none', pinned: false });
}
function select(id) {
selectedId = id;
}
function remove(id) {
const index = notes.findIndex((note) => note.id === id);
const [note] = notes.splice(index, 1);
lastDeleted = { note, index };
if (selectedId === id) selectedId = null;
}
function undo() {
if (!lastDeleted) return;
notes.splice(lastDeleted.index, 0, lastDeleted.note);
lastDeleted = null;
}
function onKey(event) {
if (event.key === 'Escape') {
selectedId = null;
}
if (event.ctrlKey && event.key === 'z' && lastDeleted) {
event.preventDefault();
undo();
}
}
</script>
<svelte:window onkeydown={onKey} />
<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 {note} selected={note.id === selectedId} onselect={select} ondelete={remove} />
{/each}
</ul>
{/if}
{#if lastDeleted}
<div class="undo-bar" role="status">
<span>{lastDeleted.note.title} deleted</span>
<button onclick={undo}>Undo</button>
</div>
{/if}
<button class="new" 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);
}
.new {
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;
}
.undo-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
margin-top: 24px;
padding: 0 8px 0 16px;
color: var(--nb-paper);
background: var(--nb-ink);
border-radius: var(--nb-radius);
}
.undo-bar button {
height: 32px;
padding: 0 12px;
font: inherit;
font-weight: 700;
color: var(--nb-paper);
background: none;
border: 1px solid var(--nb-paper);
cursor: pointer;
}
</style>
Careful: a <div> with onclick is not a button
Press Tab: the div is skipped. A <button> is focusable, answers Enter and Space, and announces itself as a button, all for free. Svelte even warns you about the div. If it does something, it is a button. If it goes somewhere, it is a link. With real buttons, keyboard support is already there.
Events you will use this term¶
| Attribute | Fires when | Typical action |
|---|---|---|
onclick |
The element is clicked, or activated with Enter or Space if it is a button | Do what the label promises |
oninput |
The value of a field changes, at every key | Live search, counters (bind:value usually does it for you) |
onchange |
A checkbox, radio or select changes | Toggle, filter |
onsubmit |
A form is sent | Validate, then save |
onkeydown |
A key goes down | Shortcuts |
onblur |
The element loses focus | Validate a field when the user leaves it |
Your turn: pin and the Delete key¶
The handout at the top of this page adds two lines to the contract: a Pin button on each row, and the Del key to delete the selected note. If you get stuck:
| You see | Why |
|---|---|
The row flips note.pinned itself |
It seems to work, but the notes belong to the page. The row rings a bell (onpin); the page toggles |
| A new delete function for the key, with no Undo | One action, one function: call remove(selectedId) |
| The Del key throws an error when nothing is selected | Check that selectedId has a value first |
| Del inside the search box deletes the selected note | The window hears every key, even while you type. Also check !event.target.matches('input, textarea') |
| Your small laptop has no Del key | Try Fn+Backspace |
Then write the event-action table of your own app, and wire at least five of its lines, with at least one callback prop and one keyboard shortcut. The Svelte tutorial, part Basic Svelte, has an Events section for extra practice.
Key takeaways¶
- An event is what happens, an action is the answer, a handler is the wire between them. Write the event-action table before the code.
- In Svelte 5, events are attributes:
onclick, lower case, no colon. - Handlers with an argument need an arrow function:
onclick={() => remove(note.id)}. Without one, name the function:onclick={undo}. - Props down, events up: a callback prop lets a child announce, and the owner of the state acts.
- The event object tells you what happened (
event.key) and lets you cancel the browser's own reaction (event.preventDefault()).<svelte:window>listens to the whole window. - Forgiveness: Undo is better than "Are you sure?". And real
<button>elements give you the keyboard for free.
Quick check¶
1. In the doorbell analogy, what does the wire stand for?
- The handler, the function that connects the event to the action
- The event, the thing that happens when someone presses
- The action, the thing the app does in answer
- The prop, the value that the parent gives the child
The press is the event, the chime is the action, and the wire between them is the handler. A button without a handler is decoration.
2. How do you listen for clicks on a button in Svelte 5?
-
<button onclick={save}> -
<button on:click={save}> -
<button onClick="save"> -
<button click={save}>
In Svelte 5 events are ordinary attributes: onclick, lower case, no colon. on:click is the Svelte 4 form you will still see in old tutorials.
3. Each row must call remove with its own id when clicked. Which handler is right?
-
onclick={() => remove(note.id)} -
onclick={remove(note.id)} -
onclick="remove(note.id)" -
onclick={remove}{note.id}
The braces need a function. The arrow function says "when clicked, then call this". onclick={remove(note.id)} hands over the result of a call instead, and the click ends in an error.
4. NoteRow needs to tell the page that the user clicked Delete. What is the right way?
- The page passes a function as a prop, and the row calls it with the note's id
- The row removes the note from the object it received as a prop
- The row reads the page's
notesarray and changes it directly - The page checks the row every second to see if it was clicked
Props down, events up. A callback prop lets the child announce what happened, and the page, which owns the state, decides what to do.
5. What does event.preventDefault() do in a keyboard handler?
- It cancels what the browser would do by itself with that key
- It stops your handler from running the next time
- It stops the user from pressing any other key
- It restores the page to its default state
Every handler receives the event object. preventDefault() cancels the browser's own reaction, such as its undo inside a text field. That is why you only call it when you really have something to undo.
6. Why does the Undo timer's effect return () => clearTimeout(timer)?
- So the old timer is cancelled before the effect runs again
- So the note is put back when the five seconds are over
- So the effect only ever runs once in the life of the page
- So the timer keeps running after the user closes the tab
The function an effect returns is its clean-up. Without it, deleting two notes quickly would leave the first timer running, and it would hide the bar of the second note too soon.
7. A developer builds a Save control with a <div onclick={save}>. What breaks?
- Keyboard users cannot reach it, and screen readers do not announce a button
- Nothing breaks, because a
<div>and a<button>behave the same way - The click never fires, because a
<div>cannot receive any events - The styles stop applying, because a
<div>cannot be scoped
A real <button> is focusable, answers Enter and Space, and announces itself. A <div> does none of that, and Svelte warns you about it.
8. A photo gallery app has a Next button and also uses the right arrow key to go to the next photo. How should you build this?
- One
next()function, called by the button's click and by a window keydown handler - Two copies of the same function, one for the button and one for the key
- A window keydown handler alone, since a button is not needed if a key exists
- An effect that checks once per second whether the key is being pressed
One action, one function. Two events can share one handler, so they can never drift apart. The button stays too, because not everyone uses a keyboard.
Every control does something now. Next comes the event with the most ways to go wrong: submit. A form, and what to do when the user types nonsense: Forms and validation.