Four Patterns for Data Flow
Components talk to each other. Parents pass data down. Children notify parents of events. Sometimes data flows both ways.
Svelte 5 gives you clear patterns for each:
- $props — Data flowing down
- Callbacks — Events flowing up
- $bindable — Two-way binding (use sparingly)
- Snippets — Flexible content composition
Master these four, and you can build any component API.
Props: Data Down
Props are how parents pass data to children. Declare them with $props():
<!-- Button.svelte -->
<script>
let { label, variant = 'primary', disabled = false } = $props();
</script>
<button class="btn btn-{variant}" {disabled}>
{label}
</button> Usage:
<Button label="Save" />
<Button label="Cancel" variant="secondary" />
<Button label="Delete" variant="danger" disabled={isDeleting} /> Props are reactive. When the parent’s value changes, the child updates automatically.
Rest props
Capture remaining props with rest syntax:
<!-- Input.svelte -->
<script>
let { label, error, ...rest } = $props();
</script>
<label>
{label}
<input {...rest} class:error={!!error} />
{#if error}
<span class="error-message">{error}</span>
{/if}
</label> Now any HTML input attribute works:
<Input label="Email" type="email" required placeholder="you@example.com" />
<Input label="Age" type="number" min={0} max={120} /> TypeScript
Type your props for better DX:
<script lang="ts">
interface Props {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onclick?: () => void;
}
let { label, variant = 'primary', disabled = false, onclick }: Props = $props();
</script> The compiler and your editor now catch mistakes.
Callbacks: Events Up
When children need to notify parents, use callback props:
<!-- SearchInput.svelte -->
<script>
let { value = '', onchange, onsearch } = $props();
function handleKeydown(event) {
if (event.key === 'Enter') {
onsearch?.(value);
}
}
</script>
<input
type="search"
{value}
oninput={(e) => onchange?.(e.target.value)}
onkeydown={handleKeydown}
/> Usage:
<script>
let query = $state('');
function handleSearch(value) {
console.log('Searching for:', value);
}
</script>
<SearchInput
value={query}
onchange={(v) => query = v}
onsearch={handleSearch}
/> The child calls the callback. The parent decides what to do. Data flows up through function calls.
Convention: on* prefix
Name callback props with on prefix: onclick, onchange, onsubmit, onselect. This matches HTML event naming and makes the prop’s purpose clear.
<script>
let {
onselect, // Called when user selects an item
ondelete, // Called when user deletes an item
onreorder // Called when user reorders items
} = $props();
</script> Bindable: Two-Way Binding
Sometimes props need to flow both directions. A form input’s value changes from both parent and child. Use $bindable():
<!-- TextInput.svelte -->
<script>
let { value = $bindable(''), label } = $props();
</script>
<label>
{label}
<input bind:value />
</label> The parent can now use bind::
<script>
let name = $state('');
</script>
<TextInput label="Name" bind:value={name} />
<p>Hello, {name}</p> Changes flow both ways:
- Parent changes
name→ input updates - User types in input →
nameupdates
When to use $bindable
Two-way binding is powerful but creates implicit coupling. Use it for:
- Form inputs (text, checkbox, select)
- Controlled components that wrap native inputs
- Modal/dialog open state
Don’t use it for:
- Action callbacks (use
onclick,onsubmit) - Complex state (use explicit callbacks)
- Anything where the child shouldn’t modify the value
When in doubt, use callbacks. They’re more explicit:
<!-- Explicit: parent controls the value -->
<TextInput
value={name}
onchange={(v) => name = v}
/>
<!-- Implicit: either side can change it -->
<TextInput bind:value={name} /> Both work. The explicit version makes data flow clearer.
Snippets: Flexible Content
Snippets replace slots from Svelte 4. They’re more powerful and more explicit.
Basic snippets
<!-- Card.svelte -->
<script>
let { header, children } = $props();
</script>
<div class="card">
{#if header}
<div class="card-header">
{@render header()}
</div>
{/if}
<div class="card-body">
{@render children()}
</div>
</div> Usage:
<Card>
{#snippet header()}
<h2>Card Title</h2>
{/snippet}
<p>This is the card content.</p>
</Card> Content between the component tags (without a {#snippet} wrapper) becomes the children prop.
Snippets with parameters
Snippets can receive data from the component:
<!-- List.svelte -->
<script>
let { items, renderItem } = $props();
</script>
<ul>
{#each items as item, index}
<li>
{@render renderItem(item, index)}
</li>
{/each}
</ul> Usage:
<script>
let users = $state([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]);
</script>
<List items={users}>
{#snippet renderItem(user, index)}
<span>{index + 1}. {user.name}</span>
{/snippet}
</List> The component controls when to render. The parent controls what to render. This is the “render prop” pattern, made clean.
Real example: Modal
<!-- Modal.svelte -->
<script>
let { open = $bindable(false), title, children, footer } = $props();
function close() {
open = false;
}
</script>
{#if open}
<div class="modal-backdrop" onclick={close}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<header class="modal-header">
<h2>{title}</h2>
<button class="close" onclick={close}>×</button>
</header>
<div class="modal-body">
{@render children()}
</div>
{#if footer}
<footer class="modal-footer">
{@render footer()}
</footer>
{/if}
</div>
</div>
{/if} Usage:
<script>
let showConfirm = $state(false);
function handleDelete() {
// ... delete logic
showConfirm = false;
}
</script>
<button onclick={() => showConfirm = true}>Delete</button>
<Modal bind:open={showConfirm} title="Confirm Delete">
<p>Are you sure you want to delete this item?</p>
{#snippet footer()}
<button onclick={() => showConfirm = false}>Cancel</button>
<button class="danger" onclick={handleDelete}>Delete</button>
{/snippet}
</Modal> The modal handles:
- Open/close state (via
$bindable) - Layout and styling
- Backdrop click to close
The parent handles:
- When to show it
- What content to display
- What actions to offer
Clean separation.
Putting It Together: DataTable
A real component often uses all these patterns:
<!-- DataTable.svelte -->
<script>
let {
items,
columns,
renderCell,
onrowclick,
selectedId = $bindable(null)
} = $props();
</script>
<table>
<thead>
<tr>
{#each columns as column}
<th>{column.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each items as item (item.id)}
<tr
class:selected={item.id === selectedId}
onclick={() => {
selectedId = item.id;
onrowclick?.(item);
}}
>
{#each columns as column}
<td>
{#if renderCell}
{@render renderCell(item, column)}
{:else}
{item[column.key]}
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table> Usage:
<script>
let users = $state([/* ... */]);
let selectedUserId = $state(null);
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' }
];
function handleRowClick(user) {
console.log('Selected:', user);
}
</script>
<DataTable
items={users}
{columns}
bind:selectedId={selectedUserId}
onrowclick={handleRowClick}
>
{#snippet renderCell(user, column)}
{#if column.key === 'role'}
<span class="badge badge-{user.role}">{user.role}</span>
{:else}
{user[column.key]}
{/if}
{/snippet}
</DataTable> This table uses:
- Props:
items,columns(data down) - Callbacks:
onrowclick(events up) - Bindable:
selectedId(two-way for controlled selection) - Snippets:
renderCell(flexible rendering)
The Hierarchy
When designing component APIs, think in this order:
- Props first. Most data flows down.
- Callbacks for events. When children need to notify parents.
- Bindable for forms. When the same value is edited by both sides.
- Snippets for content. When parents need to customize what’s rendered.
Keep it simple. A component with 3 props is easier to use than one with 10. Start minimal, add props when you need them.
Next up: We’ve covered how to build. Now let’s talk about maintaining what you’ve built. Starting with the question everyone asks too early: “Should I use a global store?”