Props & Events
Components are only useful if they can communicate. In Svelte 5, $props() handles incoming data and $bindable() enables two-way binding. Events are just callback props. This topic covers how data flows between parent and child components.
$props(): Receiving Data
Every prop a component accepts is declared through $props(). You destructure the props you need:
<!-- Greeting.svelte -->
<script>
let { name, greeting = 'Hello' } = $props();
</script>
<p>{greeting}, {name}!</p>
<!-- Parent.svelte -->
<script>
import Greeting from './Greeting.svelte';
</script>
<Greeting name="Alice" />
<Greeting name="Bob" greeting="Hey" />
Hello, Alice!
Hey, Bob!
Default values work exactly like JavaScript destructuring defaults. If the parent does not pass a prop, the default is used.
Typing Props with TypeScript
Define an interface for your props and apply it to the destructuring:
<!-- UserCard.svelte -->
<script lang="ts">
interface Props {
name: string;
email: string;
avatar?: string;
role?: 'admin' | 'user' | 'guest';
}
let { name, email, avatar = '/default-avatar.png', role = 'user' }: Props = $props();
</script>
<div class="card">
<img src={avatar} alt="{name}'s avatar">
<h3>{name}</h3>
<p>{email}</p>
<span class="badge">{role}</span>
</div>
TypeScript catches errors at build time when the parent passes wrong types or omits required props. This is one of the strongest arguments for using TypeScript with Svelte.
Passing Callbacks for Events
Svelte 5 does not have a special event system like Svelte 4's createEventDispatcher. Events are just callback props. The parent passes a function, and the child calls it.
<!-- SearchInput.svelte -->
<script lang="ts">
interface Props {
value: string;
onsearch: (query: string) => void;
onclear?: () => void;
}
let { value, onsearch, onclear }: Props = $props();
</script>
<div class="search">
<input
{value}
oninput={(e) => onsearch(e.currentTarget.value)}
placeholder="Search..."
>
{#if value && onclear}
<button onclick={onclear}>Clear</button>
{/if}
</div>
<!-- Parent.svelte -->
<script>
import SearchInput from './SearchInput.svelte';
let query = $state('');
function handleSearch(value) {
query = value;
// Could also debounce, fetch results, etc.
}
</script>
<SearchInput value={query} onsearch={handleSearch} onclear={() => query = ''} />
<p>Searching for: {query}</p>
This approach is simpler and more flexible than dispatching custom events. The callback can accept any arguments, return values, and be typed precisely.
$bindable(): Two-Way Binding
Sometimes you want the child to update a value that the parent owns. $bindable() marks a prop as supporting two-way binding:
<!-- Toggle.svelte -->
<script lang="ts">
let { checked = $bindable(false) }: { checked: boolean } = $props();
</script>
<label class="toggle">
<input type="checkbox" bind:checked>
<span class="slider"></span>
</label>
<!-- Parent.svelte -->
<script>
import Toggle from './Toggle.svelte';
let darkMode = $state(false);
</script>
<Toggle bind:checked={darkMode} />
<p>Dark mode is {darkMode ? 'on' : 'off'}</p>
When the user clicks the toggle, checked changes inside the child, and because of bind:, darkMode updates in the parent too. Without $bindable(), attempting to use bind:checked from the parent would be a compiler error.
When to Use $bindable vs Callbacks
Use $bindable for form-like components where two-way data flow is the natural model:
- Text inputs, selects, checkboxes.
- Color pickers, date pickers, sliders.
- Any component that wraps a native form element.
Use callbacks when:
- The interaction is an event, not a state synchronization (e.g., "submitted", "deleted", "selected").
- The parent needs to do more than just update a variable (e.g., make an API call).
- You want the data flow to be explicitly one-directional.
Spread Props
When you need to forward all props to a child element or component, use the spread operator:
<!-- Button.svelte -->
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary' | 'danger';
}
let { variant = 'primary', children, ...rest }: Props = $props();
</script>
<button class="btn btn-{variant}" {...rest}>
{@render children()}
</button>
<!-- Parent.svelte -->
<script>
import Button from './Button.svelte';
</script>
<Button variant="primary" onclick={() => save()} disabled={!isValid}>
Save Changes
</Button>
<Button variant="danger" onclick={() => remove()} aria-label="Delete item">
Delete
</Button>
The ...rest captures every prop not explicitly destructured. Spreading them onto the <button> element forwards attributes like disabled, aria-label, class, and event handlers.
Rest Props
Rest props are useful for wrapper components that need to forward unknown attributes:
<!-- Input.svelte -->
<script lang="ts">
let { label, error, ...inputProps }: {
label: string;
error?: string;
[key: string]: unknown;
} = $props();
</script>
<div class="field">
<label>{label}</label>
<input {...inputProps}>
{#if error}
<span class="error">{error}</span>
{/if}
</div>
<Input
label="Email"
type="email"
placeholder="you@example.com"
required
error={errors.email}
/>
The component picks off label and error, then forwards everything else (type, placeholder, required) directly to the <input> element.
The Parent-to-Child Data Flow
Svelte's data flow is top-down by default. Parents pass data to children through props. Children communicate back through callbacks or $bindable props.
<!-- TodoApp.svelte -->
<script>
import TodoList from './TodoList.svelte';
import AddTodo from './AddTodo.svelte';
let todos = $state([
{ id: 1, text: 'Learn Svelte', done: false },
{ id: 2, text: 'Build something', done: false }
]);
function addTodo(text) {
todos.push({ id: Date.now(), text, done: false });
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
function removeTodo(id) {
const index = todos.findIndex(t => t.id === id);
if (index !== -1) todos.splice(index, 1);
}
</script>
<h1>Todos</h1>
<AddTodo onadd={addTodo} />
<TodoList {todos} ontoggle={toggleTodo} onremove={removeTodo} />
<!-- AddTodo.svelte -->
<script lang="ts">
let { onadd }: { onadd: (text: string) => void } = $props();
let text = $state('');
function submit(e: Event) {
e.preventDefault();
if (text.trim()) {
onadd(text.trim());
text = '';
}
}
</script>
<form onsubmit={submit}>
<input bind:value={text} placeholder="What needs to be done?">
<button type="submit">Add</button>
</form>
<!-- TodoList.svelte -->
<script lang="ts">
interface Todo {
id: number;
text: string;
done: boolean;
}
let { todos, ontoggle, onremove }: {
todos: Todo[];
ontoggle: (id: number) => void;
onremove: (id: number) => void;
} = $props();
</script>
<ul>
{#each todos as todo (todo.id)}
<li>
<input type="checkbox" checked={todo.done} onchange={() => ontoggle(todo.id)}>
<span class:done={todo.done}>{todo.text}</span>
<button onclick={() => onremove(todo.id)}>Remove</button>
</li>
{/each}
</ul>
The parent owns the state. Children receive data through props and communicate intent through callbacks. The parent decides what to do.
Common Pitfalls
- Mutating props directly: Props are owned by the parent. While Svelte does not enforce immutability on object props, mutating them directly from the child creates confusing data flow. Use callbacks or
$bindableinstead. - Overusing $bindable: Two-way binding is convenient but makes data flow harder to trace. Only use it for form-like components where it is the natural pattern. For everything else, callbacks are clearer.
- Forgetting to type optional callbacks: If a callback prop is optional, check for its existence before calling it, or provide a default:
let { onclick = () => {} } = $props(). - Not using rest props for wrapper components: If your component wraps a native element, forward unrecognized props with
...rest. Otherwise consumers cannot setaria-*attributes,data-*attributes, or event handlers. - Passing too many props: If a component takes more than five or six props, consider grouping related ones into an object, splitting the component, or using context for deeply shared data.
Key Takeaways
$props()replaces Svelte 4'sexport let. Destructure props with defaults and TypeScript types.- Events are callback props. No special event system needed. Pass functions from parent to child.
$bindable()enables two-way binding for form-like components. Use it sparingly.- Spread and rest props make wrapper components practical: capture known props, forward the rest.
- Data flows down through props, intent flows up through callbacks. The parent owns the state.
- Type your props with TypeScript. It catches errors early and serves as documentation.