Skip to content

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 through bind:this
  • Use $bindable for 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?

  1. ROW cannot delete. It can only say: "Someone clicked my Delete."
  2. PAGE takes the note off the list, keeps it in a pocket and orders BAR: "Show: shopping list deleted."
  3. The user clicks Undo. BAR cannot restore anything. It says: "Someone clicked Undo."
  4. 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:

src/lib/components/NoteRow.svelte (script and markup)
<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:

src/lib/components/NoteRow.stories.svelte (changes)
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:

src/routes/+page.svelte (the minimum)
<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.

the parent passes a snippet called action
<UndoBar bind:this={undoBar} onundo={restore}>
    {#snippet action(hide)}
        <Button variant="secondary" size="sm" onclick={hide}>Close</Button>
    {/snippet}
</UndoBar>
the child draws it, or a fallback
{#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 through bind:this, which gives it a reference to the component.

Here is the bar: one prop, one event, one snippet, two methods.

src/lib/components/UndoBar.svelte
<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 show is public: it can be called from outside. undo has no export, so it is private.
  • message and visible are $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.

src/lib/components/ModeSwitch.svelte (script and markup)
<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:

src/lib/index.js
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.

src/routes/+page.svelte
<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.

src/lib/components/UndoBar.stories.svelte
<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), never remove(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 $state array

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 remove while drawing, instead of passing a function
  • remove cannot 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 show from the file UndoBar.svelte
  • By passing show to 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.