Skip to content

Props and default values

Exercise props-default-values-annex.pdf · PDF

Pick up a TV remote you have never used. Without any manual, you can tell what the TV can do: volume, channel, power. You also know what happens if you press nothing: it keeps doing what it was doing.

Click an instance in Figma and the right-hand panel is exactly that remote. A developer who has never seen the code knows the button has a Variant with three values, a Size with two, a Disabled switch and a Label. In Storybook and your first component you coded one of those studs. Now you code all of them, and decide what happens when nobody presses anything.

What you will learn

  • Declare props with $props(), and tell a required prop from an optional one
  • Choose default values from the default variant of the Figma component
  • Translate each Figma property type into a prop type
  • Pass on every prop you did not name with ...rest
  • Document props with JSDoc and drive them from the Controls panel
  • The trap: a default protects you from undefined, not from null

One line, four ideas

A prop is a value a parent passes to a component. It is one stud of the brick. A default value is what the component uses when the parent passes nothing.

Everything you need fits in one line:

let { title, preview = '', pinned = false, ...rest } = $props();
Part Meaning
title Required. No default: the row makes no sense without it
preview = '' Optional, with a default value
pinned = false Optional boolean. <NoteRow pinned /> means pinned={true}
...rest Every other prop the parent passes, collected in one object. These are called rest props

And this is how a parent passes each kind:

You write The component receives
<NoteRow title="Shopping list" /> A string. Quotes, no braces
<NoteRow title={note.title} /> A JavaScript value. Braces
<NoteRow {title} /> Shorthand for title={title}
<NoteRow pinned /> pinned is true
<NoteRow {...note} /> Every field of the object note as a prop

From Figma type to prop type

Each kind of Figma property has its counterpart in code:

In Figma In Svelte Example
Variant (a list) A string, one of a fixed list variant = 'primary'
Variant or Boolean (on/off) A boolean pinned = false
Text A string, or the children snippet title, the Button label
Instance swap A snippet (next page) An icon, a custom action

Who chooses the default?

If you write <Button> and say nothing else, who decides it is primary and medium? The designer already did: it is the default variant of the component set. A good default is the value most uses need. Then the common case stays short: <Button>Save</Button>.

Careful: not everything deserves a default

"Give everything a default, so nothing can break." Try that with title = 'Untitled'. A page forgets to pass the title, the row shows "Untitled", and nobody notices the bug. Required means required: a missing value must be noticed, not hidden.

Button grows up

Here is the Button with the missing studs from the spec: size and disabled.

src/lib/components/Button.svelte (script and markup)
<script>
    /**
     * @typedef {Object} Props
     * @property {'primary' | 'secondary' | 'danger'} [variant] How loud the button is. One primary per screen.
     * @property {'md' | 'sm'} [size] md = 48 px tall (touch), sm = 32 px (inside bars).
     * @property {boolean} [disabled] Dimmed, not removed (perceived stability).
     * @property {import('svelte').Snippet} children The label.
     */

    /** @type {Props & import('svelte/elements').HTMLButtonAttributes} */
    let { variant = 'primary', size = 'md', disabled = false, children, ...rest } = $props();
</script>

<button type="button" class="btn {variant} {size}" {disabled} {...rest}>
    {@render children()}
</button>
the same file, new rules inside <style>
.sm {
    height: 32px;
    padding: 0 12px;
    font-size: 14px;
}
.btn:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}

Three things changed.

The comment is JSDoc: a way of writing types and descriptions inside JavaScript comments, so that tools can read them. Square brackets mean optional. Your editor now autocompletes variant=" with the three values. Later, Storybook reads these same lines to build the component's documentation. The long @type line says "my props, plus everything a normal <button> accepts". Copy it; do not memorise it.

{...rest} goes on the <button>. Now <Button onclick={save} aria-label="Save note"> just works, and we never had to list those props one by one.

Disabled dims, it does not remove. That is the 1987 principle of perceived stability: a command you cannot use right now turns grey, and it stays in its place. Now it is one prop.

Checking the types from the terminal

If you created the project with --no-types, you have no npm run check script. Add the checker once, and run it whenever you want:

npm install -D svelte-check typescript
npx svelte-kit sync && npx svelte-check --tsconfig ./jsconfig.json

Write <Button variant="big"> and it answers: Type '"big"' is not assignable to type '"primary" | "secondary" | "danger" | undefined'.

Tell Storybook which values exist

Add two stories and describe the controls with argTypes:

src/lib/components/Button.stories.svelte
<script module>
    import { defineMeta } from '@storybook/addon-svelte-csf';
    import Button from './Button.svelte';

    const { Story } = defineMeta({
        title: 'NoteBox/Button',
        component: Button,
        argTypes: {
            variant: { control: 'select', options: ['primary', 'secondary', 'danger'] },
            size: { control: 'inline-radio', options: ['md', 'sm'] }
        }
    });
</script>

<Story name="Primary">Save</Story>

<Story name="Secondary" args={{ variant: 'secondary' }}>New note</Story>

<Story name="Danger" args={{ variant: 'danger' }}>Delete</Story>

<Story name="Small" args={{ variant: 'secondary', size: 'sm' }}>Undo</Story>

<Story name="Disabled" args={{ disabled: true }}>Share</Story>

Open Controls: a dropdown, two radio buttons, a switch. Where have you seen this panel before? In Figma. Same remote, same buttons.

NoteRow from a spec

Now a component from nothing: one row of the note list. The spec's property table gives you four props.

Figma property Svelte prop Default Why
Title title None: required A note row with no title is a bug
Preview (with Has preview) preview '' Empty means the line is not rendered at all
Pinned pinned false Most notes are not pinned
Selected selected false Most rows are not selected
src/lib/components/NoteRow.svelte
<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.
     */

    /** @type {Props} */
    let { title, preview = '', pinned = false, selected = false } = $props();
</script>

<div class="row" class:selected>
    <div class="text">
        <span class="title">{title}</span>
        {#if pinned}<span class="pin">Pinned</span>{/if}
        {#if preview}<span class="preview">{preview}</span>{/if}
    </div>
</div>

<style>
    .row {
        display: flex;
        align-items: center;
        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);
    }
    .text {
        flex: 1;
        display: grid;
        grid-template-columns: 1fr auto;
        gap: 2px 8px;
        padding: 8px 0;
    }
    .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);
    }
</style>
src/lib/components/NoteRow.stories.svelte
<script module>
    import { defineMeta } from '@storybook/addon-svelte-csf';
    import NoteRow from './NoteRow.svelte';

    const { Story } = defineMeta({
        title: 'NoteBox/NoteRow',
        component: NoteRow,
        args: { title: 'Shopping list' }
    });
</script>

<Story name="Default" />

<Story name="With preview" args={{ preview: 'Milk x2, eggs (6), tomatoes' }} />

<Story name="Pinned" args={{ preview: 'Milk x2, eggs (6), tomatoes', pinned: true }} />

<Story name="Selected" args={{ selected: true }} />

The args in defineMeta are shared by every story of the file, so title is written only once.

When it goes wrong

What you wrote What happens
pinned = 'false' That is a string, and any non-empty string counts as true. Every row is pinned. Booleans have no quotes
No {#if preview} An empty grey line under every title. The spec says the line is not rendered at all when empty
NoteRow and PinnedNoteRow Two bricks for one job. One brick, one more stud

Every prop is a promise

"More props means more flexible, so better." It does not. Every prop is something you must keep working and test. Add one when a real screen needs it.

The trap: undefined is not null

A default looks like a safety net for any missing value. It is not.

The parent writes The component gets
<NoteRow title="A" /> preview is '' (the default)
<NoteRow title="A" preview={undefined} /> preview is '' (the default)
<NoteRow title="A" preview={null} /> preview is null. No default

The default applies only to undefined. So when a REST API answers "preview": null, the null goes straight through. You fetched data like this in Consuming data: check what your API really sends.

Key takeaways

  • A prop with no default is required. A prop with one is optional.
  • A default value comes from the designer's default variant: the value most uses need.
  • Do not give a default to a required prop: it hides the bug of a page that forgot to pass it.
  • ...rest on the inner element lets the parent pass onclick, aria-label and anything else.
  • JSDoc feeds the editor and, later, Storybook's documentation. Square brackets mean optional.
  • Defaults apply to undefined only. null goes straight through.

Quick check

1. In let { name, age = 18 } = $props();, which prop is required?
  • name, because it has no default value
  • age, because it has a value assigned to it
  • Both of them, because they appear in $props()
  • Neither of them, because props are always optional

A prop with no default is required: the component makes no sense without it. age = 18 is optional, and 18 is what the component uses when the parent passes nothing.

2. Where should the default value of a component's size prop come from?
  • The value you needed the first time you used it
  • The smallest value in the list of possible sizes
  • The default variant the designer set in the component
  • The first value that appears in alphabetical order

The default variant of the Figma component set is the designer's decision about the most common case. Using it in code keeps both sides in agreement, and it keeps the common case short to write.

3. A component has count = 0. The parent passes count={null}. What does the component get?
  • 0, because the default covers every empty value
  • null, because only undefined uses the default
  • undefined, because Svelte removes null values
  • An error, because props cannot receive null

A default applies only when the value is undefined, meaning the prop was not passed or was passed as undefined. null is a real value and goes straight through, which is a common surprise with API data.

4. Someone writes <NoteRow title=\"A\" pinned=\"false\" />. What happens?
  • The row is not pinned, as the text says
  • Svelte converts the text into the boolean false
  • Svelte refuses to compile the component
  • The row is pinned, because the text counts as true

"false" is a string, and any non-empty string is truthy. To turn it off, write pinned={false} or leave the prop out.

5. Why does Button write {...rest} on its inner <button> element?
  • To copy the CSS rules of the parent into the button
  • To make every prop of the component required
  • To pass on props it did not name, such as onclick
  • To store the values that the user has typed

Rest props collect everything the component did not name. Spreading them on the <button> means onclick and aria-label just work, without listing each of them.

6. What do the square brackets mean in @property {boolean} [pinned]?
  • The prop holds an array of boolean values
  • The prop is optional, so the parent may omit it
  • The prop can only be changed by the component
  • The prop is shown as a switch in the Figma panel

In JSDoc, square brackets around a name mark it as optional. The editor uses that to warn you, and Storybook will use it for its props table.

7. You are writing a UserBadge with a name and a colour theme that is usually light. Which declaration fits best?
  • let { name, theme = 'light' } = $props();
  • let { name = 'User', theme = 'light' } = $props();
  • let { name, theme } = $props();
  • let { name = '', theme = '' } = $props();

A badge with no name is a bug, so name is required and has no default: a made-up 'User' would hide the mistake. theme has a common value, so it gets that as its default and the common case stays short.

Your bricks can now receive anything. But they cannot say anything back. In Methods, snippets and events, they start talking.