Methods, snippets and events¶
Exercise methods-snippets-events-annex.pdf · PDF
You delete a message by accident and a bar pops up at the bottom of the screen: "Message deleted · Undo". You tap Undo and it comes back, in the same place. You have done this a hundred times. Now try to build it.
The row has a Delete button, but it holds one title, not the list. The bar has the Undo button, but it holds nothing at all. Neither can put the note back. Three bricks that do not know each other have to do one job together. How?
What you will learn
- Make a component report what the user did with a callback prop
- Inject content with snippets:
children, a named snippet, a parameter and a fallback - Expose methods with
export function, and call them throughbind:this - Use
$bindablefor controls that are a value - Choose between a prop, an event, a snippet, a method and a bindable prop
- The trap:
ondelete={remove(id)}deletes everything as soon as the page loads
Three people, one delete¶
Imagine three people in a room. PAGE holds the list of notes, on sticky notes. ROW holds a copy of one title. BAR holds nothing and faces the wall. One rule: ROW and BAR may not talk to each other, and neither may touch PAGE's notes.
The user says: "Delete the shopping list." How do they do it?
- ROW cannot delete. It can only say: "Someone clicked my Delete."
- PAGE takes the note off the list, keeps it in a pocket and orders BAR: "Show: shopping list deleted."
- The user clicks Undo. BAR cannot restore anything. It says: "Someone clicked Undo."
- PAGE puts the note back.
flowchart LR
R["<b>NoteRow</b><br>one title, no list"] -->|"1. ondelete<br>(event goes up)"| P["<b>Page</b><br>owns the notes"]
P -->|"2. undoBar.show()<br>(an order)"| B["<b>UndoBar</b><br>faces the wall"]
B -->|"3. onundo<br>(event goes up)"| P
P -->|"4. notes again<br>(props go down)"| R
That is the whole page in one sentence: props go down, events go up, and whoever owns the data makes the decisions. Siblings never talk directly. They always go through the parent that owns both.
| Direction | Mechanism | Use it for |
|---|---|---|
| Parent to child | Prop | Data and options |
| Child to parent | Event: a function passed as a prop | "The user did something" |
| Parent to child | Snippet | Content: markup the child must draw |
| Parent to child | Exported method + bind:this |
An order: do this now |
| Both ways | $bindable prop + bind: |
Form-like controls: one value, two owners |
Events go up¶
A callback prop is a prop whose value is a function. The parent passes the function and the child calls it. In Svelte 5, this is how a component fires an event.
There is nothing special about it. By convention the name starts with on: ondelete, onopen, onundo. Exactly like onclick on a <button>, which you used in Events and actions.
NoteRow gets two new props, and its text becomes a button you can click:
<script>
/**
* @typedef {Object} Props
* @property {string} title The note's title. Required: it has no default.
* @property {string} [preview] First line of the note, in grey.
* @property {boolean} [pinned] Shows the "Pinned" label.
* @property {boolean} [selected] Highlights the row.
* @property {() => void} [onopen] Called when the row is clicked.
* @property {() => void} [ondelete] Called when Delete is clicked. No handler, no Delete button.
*/
/** @type {Props} */
let { title, preview = '', pinned = false, selected = false, onopen, ondelete } = $props();
</script>
<div class="row" class:selected>
<button type="button" class="open" onclick={() => onopen?.()}>
<span class="title">{title}</span>
{#if pinned}<span class="pin">Pinned</span>{/if}
{#if preview}<span class="preview">{preview}</span>{/if}
</button>
{#if ondelete}
<button type="button" class="delete" aria-label="Delete {title}" onclick={() => ondelete()}>
Delete
</button>
{/if}
</div>
The complete <style> of NoteRow
.row {
display: flex;
align-items: center;
gap: 8px;
min-height: 51px;
padding: 0 12px;
border-bottom: 1px solid var(--nb-line);
background: var(--nb-paper);
font-family: var(--nb-font);
}
.selected {
box-shadow: inset 4px 0 0 var(--nb-accent);
}
.open {
flex: 1;
display: grid;
grid-template-columns: 1fr auto;
gap: 2px 8px;
padding: 8px 0;
border: 0;
background: none;
text-align: left;
font: inherit;
cursor: pointer;
}
.title {
font-weight: 500;
font-size: 16px;
color: var(--nb-ink);
}
.pin {
font-size: 13px;
color: var(--nb-accent);
}
.preview {
grid-column: 1 / -1;
font-size: 13px;
color: var(--nb-muted);
}
.delete {
border: 0;
background: none;
font: 500 14px var(--nb-font);
color: var(--nb-danger);
cursor: pointer;
}
.open:focus-visible,
.delete:focus-visible {
outline: 2px solid var(--nb-accent);
outline-offset: 2px;
}
Read the new parts:
onopen?.()calls the function only if the parent passed it. Without?., a row with no handler crashes on click.{#if ondelete}: the Figma boolean Deletable is not a prop in code. If the parent cannot handle a delete, there is no Delete button to click.aria-label="Delete {title}": four buttons that all say "Delete" are useless to a screen reader. "Delete Shopping list" is not.- The two buttons are siblings. If you nest Delete inside the row's button, clicking Delete also opens the note.
In Storybook, fn() makes a function that logs its calls in the Actions panel:
import { fn } from 'storybook/test';
// ...in defineMeta:
args: { title: 'Shopping list', onopen: fn() }
// ...a new story, after the </script>:
<Story name="Deletable" args={{ ondelete: fn() }} />
Now the page does the deciding:
<script>
import NoteRow from '#lib/components/NoteRow.svelte';
let notes = $state([
{ id: 1, title: 'Shopping list', preview: 'Milk x2, eggs (6), tomatoes', pinned: true },
{ id: 2, title: 'Maths homework', preview: 'Exercises 4 to 9', pinned: false }
]);
/** @param {number} id */
function remove(id) {
const index = notes.findIndex((n) => n.id === id);
notes.splice(index, 1);
}
</script>
{#each notes as note (note.id)}
<NoteRow title={note.title} preview={note.preview} pinned={note.pinned}
ondelete={() => remove(note.id)} />
{/each}
The trap: passing a call instead of a function
ondelete={remove(note.id)} calls remove while the page is drawing, and passes its result. Every note vanishes on load. The prop must be a function: ondelete={() => remove(note.id)}.
Careful: the row should not delete the note
"It has the Delete button, so it should do the deleting." But the row does not have the list. If it deleted, you could never reuse it in the bin, in search results or in another app. It reports. The owner of the data decides.
Old tutorials
Search results and AI answers still show createEventDispatcher. That is the Svelte 3 and 4 way, and it is deprecated. In Svelte 5, use a callback prop.
Snippets go in¶
You already use a snippet. In <Button>Save</Button>, the word Save travels into the Button as a snippet called children, and {@render children()} draws it.
Could we pass the label as a prop, label="Save"? Yes, for plain text. But try an icon plus bold text. A string cannot hold markup.
A snippet is a piece of markup with a name that you can pass to a component, as if it were a value. The child decides where to draw it.
<UndoBar bind:this={undoBar} onundo={restore}>
{#snippet action(hide)}
<Button variant="secondary" size="sm" onclick={hide}>Close</Button>
{/snippet}
</UndoBar>
{#if action}
{@render action(hide)}
{:else}
<Button variant="secondary" size="sm" onclick={undo}>Undo</Button>
{/if}
| Piece | Meaning |
|---|---|
{#snippet action(hide)} |
The parent writes markup and gives it a name. hide is a parameter the child will fill in |
{@render action(hide)} |
The child draws it, passing its own hide function |
{#if action} ... {:else} |
The fallback: no snippet passed, draw the default. A default value, for markup |
children |
The snippet with no name: whatever sits between the tags |
In a Figma component set, a snippet is an instance swap property or a slot. It is the same idea: a hole that the user of the component fills.
Careful: snippets are not just renamed slots
<slot> is the Svelte 4 way. It still compiles, but it is legacy, and many tutorials that use it are out of date. Snippets are plain values you can pass as props, and they take parameters like a function. In Svelte 5, use {#snippet} and {@render}.
Methods: an order¶
How would the page tell the bar "show yourself now" with a prop? visible={true}... and who sets it back to false after five seconds? The page would have to run the bar's timer. An order is easier: undoBar.show('Shopping list deleted'), and the bar looks after itself.
An exported method is a function a component makes available to its parent with
export function. The parent reaches it throughbind:this, which gives it a reference to the component.
Here is the bar: one prop, one event, one snippet, two methods.
<script>
import Button from './Button.svelte';
/**
* @typedef {Object} Props
* @property {number} [duration] Milliseconds before the bar hides itself. 0 = stay until hide().
* @property {() => void} [onundo] Called when the user clicks Undo.
* @property {import('svelte').Snippet<[() => void]>} [action] Replaces the Undo button. Receives hide.
*/
/** @type {Props} */
let { duration = 5000, onundo, action } = $props();
let message = $state('');
let visible = $state(false);
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
/**
* Shows the bar with a message.
* @param {string} text
*/
export function show(text) {
message = text;
visible = true;
clearTimeout(timer);
if (duration > 0) timer = setTimeout(hide, duration);
}
/** Hides the bar now. */
export function hide() {
visible = false;
clearTimeout(timer);
}
function undo() {
hide();
onundo?.();
}
</script>
<div role="status">
{#if visible}
<div class="bar">
<span>{message}</span>
{#if action}
{@render action(hide)}
{:else}
<Button variant="secondary" size="sm" onclick={undo}>Undo</Button>
{/if}
</div>
{/if}
</div>
<style>
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 0 16px;
border-radius: var(--nb-radius);
background: var(--nb-ink);
color: var(--nb-paper);
font: 500 14px var(--nb-font);
}
</style>
Read it like Java from first year:
export function showis public: it can be called from outside.undohas noexport, so it is private.messageandvisibleare$state, not props: they are the bar's own business. They work like private fields with public methods.role="status"sits on a wrapper that is always there. A screen reader announces text that appears inside it. If the whole thing appeared at once, many would stay silent.- The bar reuses
Button. Bricks made of bricks.
Facts are props, orders are methods
"Methods look more powerful, so I will use them for everything." A method is an order given once. A prop is a fact that stays true. title is a fact, so it is a prop. "Show now", "focus" and "reset" are orders, so they are methods. If you find yourself writing setTitle(), you wanted a prop.
bind:: one value, two owners¶
You used bind:value on an <input> in Forms and validation. $bindable is how your own component allows the same thing.
A bindable prop is a prop that the parent and the child share, so a change from either side reaches the other. You declare it with
$bindable.
<script>
/**
* @typedef {Object} Props
* @property {'read' | 'edit'} [value] The current mode. Bindable: the parent can use bind:value.
*/
/** @type {Props} */
let { value = $bindable('read') } = $props();
</script>
<div class="switch" role="group" aria-label="Mode">
<button type="button" aria-pressed={value === 'read'} onclick={() => (value = 'read')}>
Read-only
</button>
<button type="button" aria-pressed={value === 'edit'} onclick={() => (value = 'edit')}>
Edit
</button>
</div>
In the page: let mode = $state('read'); and <ModeSwitch bind:value={mode} />. The argument of $bindable, here 'read', is the default.
Use this only for controls that are a value: switches, inputs, pickers. With two owners for every value, nobody knows who changed what. For everything else: props down, events up.
The whole circuit¶
Export your components from one file:
export { default as Button } from './components/Button.svelte';
export { default as NoteRow } from './components/NoteRow.svelte';
export { default as UndoBar } from './components/UndoBar.svelte';
export { default as ModeSwitch } from './components/ModeSwitch.svelte';
Now wire the page. Compare it with the diagram at the top: the four arrows are all here.
<script>
import { Button, NoteRow, UndoBar, ModeSwitch } from '#lib';
let notes = $state([
{ id: 1, title: 'Shopping list', preview: 'Milk x2, eggs (6), tomatoes', pinned: true },
{ id: 2, title: 'Maths homework', preview: 'Exercises 4 to 9', pinned: false },
{ id: 3, title: 'Ideas for the app', preview: '', pinned: false }
]);
/** @type {'read' | 'edit'} */
let mode = $state('read');
/** @type {{ note: (typeof notes)[number], index: number } | undefined} */
let lastDeleted;
/** @type {UndoBar} */
let undoBar;
/** @param {number} id */
function remove(id) {
const index = notes.findIndex((n) => n.id === id);
lastDeleted = { note: notes[index], index };
notes.splice(index, 1);
undoBar.show(`${lastDeleted.note.title} deleted`);
}
function restore() {
if (!lastDeleted) return;
notes.splice(lastDeleted.index, 0, lastDeleted.note);
lastDeleted = undefined;
}
</script>
<main>
<h1>NoteBox</h1>
<ModeSwitch bind:value={mode} />
{#each notes as note (note.id)}
<NoteRow
title={note.title}
preview={note.preview}
pinned={note.pinned}
ondelete={mode === 'edit' ? () => remove(note.id) : undefined}
/>
{/each}
<Button disabled={mode === 'read'}>New note</Button>
<UndoBar bind:this={undoBar} onundo={restore} />
</main>
If you have not built ModeSwitch yet, leave it out and pass ondelete={() => remove(note.id)} directly. To check the restore, delete the first note and click Undo: it must come back first. That is why the code saves index and uses splice rather than push.
A story for the bar¶
The bar is empty until someone calls show(), so its story needs a button that calls it.
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { expect, fn } from 'storybook/test';
import Button from './Button.svelte';
import UndoBar from './UndoBar.svelte';
const { Story } = defineMeta({
title: 'NoteBox/UndoBar',
component: UndoBar,
args: { duration: 0, onundo: fn() }
});
</script>
<script>
/** @type {UndoBar} */
let bar;
</script>
<!-- The bar is empty until someone calls show(): the story needs a button to call it. -->
<Story
name="After a delete"
play={async ({ canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Delete a note' }));
await expect(canvas.getByRole('status')).toHaveTextContent('Shopping list deleted');
}}
>
{#snippet template(args)}
<div style="display: grid; gap: 12px; max-width: 350px">
<Button variant="danger" onclick={() => bar.show('Shopping list deleted')}>Delete a note</Button>
<UndoBar bind:this={bar} {...args} />
</div>
{/snippet}
</Story>
template is a snippet too: the story decides what surrounds the component. play clicks for you when the story opens, so the catalogue photo shows the bar visible. The <script module> must come before the normal <script>.
When it goes wrong
| What you see | Why |
|---|---|
undoBar is undefined |
bind:this is missing, or you call show at the top of the script, before the component exists |
undoBar.show is not a function |
show has no export |
| Undo puts the note at the end | You used push instead of splice(index, 0, note) |
| The bar hides in the page's demo but never in your app | You copied duration: 0 from the story into the page |
You are passing notes to UndoBar |
The bar faces the wall. It reports; the page decides |
Which mechanism?¶
| The component needs... | Use | Example |
|---|---|---|
| A value to display, or an option | Prop | title, variant, duration |
| To tell the parent the user did something | Event (callback prop) | ondelete, onundo, onopen |
| Markup decided by the parent | Snippet | children, action |
| To obey an order at a given moment | Exported method | show(), hide() |
| To share one value both ways | $bindable prop |
value of ModeSwitch |
Key takeaways¶
- Props go down, events go up, and whoever owns the data makes the decisions.
- A callback prop is a prop that holds a function. Pass
() => remove(id), neverremove(id). - A snippet carries markup into a component.
{#if}around{@render}gives it a fallback. - An exported method is an order, called through
bind:this. A prop is a fact. - A bindable prop is for controls that are a value. Keep it to those.
- Siblings never talk directly: they go through the parent that owns both.
Quick check¶
1. In Svelte 5, how does a child component tell its parent that something happened?
- It changes a variable that belongs to the parent
- It calls a function that the parent passed as a prop
- It dispatches through
createEventDispatcher - It writes the event into a shared
$statearray
An event is a callback prop: a prop whose value is a function. createEventDispatcher belongs to Svelte 3 and 4 and is deprecated. The child calls the function, and the parent decides what it means.
2. Why does ondelete={remove(note.id)} make every note vanish on load?
- It calls
removewhile drawing, instead of passing a function -
removecannot receive an argument through a prop - The
{#each}block runs the handler once per row - Svelte clicks every button once when it mounts them
With the parentheses, the function runs right away and its result becomes the prop. To run it on click, pass a function: () => remove(note.id).
3. Why should NoteRow not remove itself from the list?
- A component cannot change arrays declared with
$state - The row has no way to know when it was clicked
- Svelte does not allow a component to remove itself
- It does not own the list, and that would stop it being reusable
The row holds one title. If it reached into a specific list, it could not be reused in another screen. It reports, and the owner of the data decides.
4. What does {@render action(hide)} do in a child component?
- It calls an exported method of the parent component
- It draws the snippet from the parent, passing it the child's
hide - It sends an event named action to the parent component
- It declares a new snippet that the parent is able to use
{@render} draws a snippet. Here the parent wrote the markup and the child fills in the parameter, so the parent's button can call the child's own hide function.
5. Why is show() an exported method rather than a visible prop?
- Props cannot hold boolean values in a component
- Exported methods render faster than props do
- With a prop, the page would have to run the bar's timer
- A screen reader can only announce through a method
A prop is a fact that stays true. "Show now" is an order: with show(), the bar sets its own timer and hides itself, and the page does not need to know how.
6. How does a page get a reference it can use to call undoBar.show()?
- By importing
showfrom the fileUndoBar.svelte - By passing
showto the bar as a callback prop - By reading the bar from the
$props()of the page - By writing
bind:this={undoBar}on the component
bind:this stores a reference to the component in a variable. Through that reference, the parent can call whatever the component marked with export function.
7. When is $bindable the right choice?
- For a control that is a value, such as a switch or a picker
- For every prop, because it avoids writing any events
- For any method that a parent needs to call on a child
- For sharing data between two sibling components directly
A bindable prop gives a value two owners. That is natural for form-like controls, and confusing elsewhere, because nobody knows who changed what.
8. A Modal has a title and a Close button. The page must give it a body with a form, and must open it when the user clicks Settings. Which mechanisms fit?
- A string prop for the body and a bindable prop for the order
- An exported method for the body and a snippet for opening it
- A snippet for the body and an exported
open()method - A callback prop for the body and a snippet for opening it
A form is markup, and a string cannot hold markup, so the body is a snippet. "Open now" is an order given at one moment, so it is an exported method. A bindable prop would also work for showing, but it would give that value two owners for no gain.
Until now you have checked all of this by clicking. In Unit tests with Vitest you write tests that click for you.