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 fromnull
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:
| 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.
<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>
.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:
<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 |
<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>
<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.
...reston the inner element lets the parent passonclick,aria-labeland anything else.- JSDoc feeds the editor and, later, Storybook's documentation. Square brackets mean optional.
- Defaults apply to
undefinedonly.nullgoes 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 onlyundefineduses 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.