Two-Way Data Flow Between Components

In the world of component-based frameworks, data typically flows in one direction: from parent to child. This unidirectional data flow makes applications predictable and easier to debug—you always know where state lives and how it changes. Svelte embraces this philosophy by default, but recognizes that certain patterns genuinely benefit from bidirectional communication.

The $bindable rune in Svelte 5 provides a controlled escape hatch from unidirectional data flow, enabling props to be bound so that changes in the child component can flow back up to the parent. This isn’t a pattern to reach for casually—overuse leads to unpredictable data flow and components that are difficult to maintain. But when used sparingly and intentionally, $bindable can dramatically simplify code that would otherwise require verbose callback props.

This tutorial explores $bindable comprehensively: from the fundamental mechanics to advanced patterns, from common pitfalls to real-world use cases. By the end, you’ll understand not just how to use this rune, but when it’s the right tool for the job.


The Problem: Child-to-Parent Communication

Consider building a reusable form input component. The parent needs to know the current value, and the child manages the actual input element. Without two-way binding, you’d write:

<!-- TextInput.svelte -->
<script>
	let { value, onValueChange } = $props()
</script>

<input type="text" {value} oninput={(e) => onValueChange(e.target.value)} />
<!-- App.svelte -->
<script>
	import TextInput from './TextInput.svelte'

	let username = $state('')
</script>

<TextInput value={username} onValueChange={(v) => (username = v)} /><p>Username: {username}</p>

This works, but it’s verbose. You’re declaring two props (value and onValueChange), and the parent must wire up a callback just to keep state in sync. For a single input, this is manageable. For a complex form with dozens of fields, the boilerplate multiplies rapidly.

The $bindable rune offers a cleaner alternative:

<!-- TextInput.svelte -->
<script>
	let { value = $bindable() } = $props()
</script>

<input type="text" bind:value />
<!-- App.svelte -->
<script>
	import TextInput from './TextInput.svelte'

	let username = $state('')
</script>

<TextInput bind:value={username} /><p>Username: {username}</p>

The parent uses bind:value to establish a two-way connection. Changes to the input in TextInput automatically update username in App. The child component’s code is simpler, and the parent’s intent is clearer.


Basic Syntax and Mechanics

The $bindable rune marks a prop as capable of two-way binding. It’s used within the $props() destructuring:

<script>
	let { propName = $bindable() } = $props()
</script>

Key Characteristics

  1. Optional binding: Marking a prop as $bindable means it can be bound, not that it must be. Parents can still pass regular (non-bound) props.

  2. Fallback values: You can provide a default value that applies when no prop is passed:

<script>
	let { value = $bindable('default text') } = $props()
</script>
  1. Mutation allowed: Unlike regular props, bindable props can be mutated within the child component, and those mutations flow back to the parent.

The Parent’s Choice

The parent component decides whether to bind:

<script>
	import TextInput from './TextInput.svelte'

	let boundValue = $state('I am bound')
	let unboundValue = 'I am not bound'
</script>

<!-- Two-way binding: changes flow both directions -->
<TextInput bind:value={boundValue} />

<!-- One-way prop: changes in TextInput don't affect unboundValue -->
<TextInput value={unboundValue} />

<!-- No prop at all: uses the fallback value -->
<TextInput />

This flexibility is crucial—the same component can be used in both controlled (bound) and uncontrolled (prop-only) scenarios.


How $bindable Differs from Regular Props

Understanding the distinction between regular props and bindable props is essential for using them correctly.

Regular Props: Read-Only by Convention

With regular props, you can technically reassign them within the child component, but this creates a temporary local override that doesn’t affect the parent:

<!-- Counter.svelte -->
<script>
	let { count } = $props()
</script>

<button onclick={() => count++}>
	Count: {count}
</button>
<!-- App.svelte -->
<script>
	import Counter from './Counter.svelte'

	let parentCount = $state(0)
</script>

<Counter count={parentCount} />
<p>Parent sees: {parentCount}</p>

<button onclick={() => parentCount++}>Increment from parent</button>

Clicking the child’s button increments the local count, but parentCount remains unchanged. When the parent updates parentCount, the child’s local override is lost. This behavior is intentional—it prevents unexpected data flow while allowing temporary local state.

Bindable Props: True Two-Way Communication

With $bindable, mutations in the child immediately reflect in the parent:

<!-- Counter.svelte -->
<script>
	let { count = $bindable(0) } = $props()
</script>

<button onclick={() => count++}>
	Count: {count}
</button>
<!-- App.svelte -->
<script>
	import Counter from './Counter.svelte'

	let parentCount = $state(0)
</script>

<Counter bind:count={parentCount} />
<p>Parent sees: {parentCount}</p>

<button onclick={() => parentCount++}>Increment from parent</button>

Now both buttons affect the same value. The child’s increment updates parentCount, and the parent’s increment updates the child’s display. True bidirectional synchronization.


Fallback Values: The Subtleties

Fallback values in $bindable have specific behavior that differs from regular prop defaults:

When Fallbacks Apply

The fallback value is used only when:

  1. No prop is passed at all
  2. The prop is passed as undefined
<!-- Toggle.svelte -->
<script>
	let { checked = $bindable(false) } = $props()
</script>

<input type="checkbox" bind:checked />
<span>{checked ? 'On' : 'Off'}</span>
<!-- App.svelte -->
<script>
	import Toggle from './Toggle.svelte'

	let explicitValue = $state(true)
	let undefinedValue = $state(undefined)
</script>

<!-- Uses explicitValue (true) -->
<Toggle bind:checked={explicitValue} />

<!-- Uses fallback (false) because value is undefined -->
<Toggle bind:checked={undefinedValue} />

<!-- Uses fallback (false) because no prop passed -->
<Toggle />

Bound Props Must Have Values

When a prop is bound (using bind:), the parent is expected to provide a defined value. If a bound prop receives undefined while having a fallback, Svelte throws a runtime error:

<!-- This will throw an error! -->
<script>
	import Toggle from './Toggle.svelte'

	let value = $state(undefined)
</script>

<Toggle bind:checked={value} />
<!-- Runtime error: bound prop 'checked' is undefined -->

This strictness prevents ambiguous situations where it’s unclear whether the fallback or the parent’s value should apply.

Bound Props Must Have Values

Binding to undefined Throws at Runtime

When you use bind: on a prop, Svelte requires the parent value to be defined. If the parent provides undefined to a bound prop that has a fallback, Svelte throws a runtime error. Always initialise your state before binding: let value = $state('').

Unbound Props Can Be Undefined

For unbound props, undefined simply triggers the fallback:

<!-- This works fine -->
<Toggle checked={undefined} />
<!-- Uses fallback value (false) -->

Binding to Object Properties and State Proxies

When working with objects, $bindable interacts with Svelte’s reactivity system in important ways.

Binding Object Properties

You can bind to properties of reactive objects:

<!-- App.svelte -->
<script>
	import TextInput from './TextInput.svelte'

	let form = $state({
		username: '',
		email: '',
		password: ''
	})
</script>

<TextInput bind:value={form.username} placeholder="Username" />
<TextInput bind:value={form.email} placeholder="Email" />
<TextInput bind:value={form.password} placeholder="Password" type="password" />

<pre>{JSON.stringify(form, null, 2)}</pre>

Each input binds to a specific property of the form object. Changes in any input update the corresponding property reactively.

State Proxy Mutations

When a $state proxy is passed as a prop (bound or not), mutations to that proxy in the child will affect the parent. However, there’s a crucial distinction:

Without $bindable: Mutating a state proxy prop triggers a warning because the child is modifying state it doesn’t “own”:

<!-- Child.svelte -->
<script>
	let { user } = $props() // Not bindable
</script>

<button onclick={() => (user.name = 'Changed')}> Change name </button>
<!-- Works but shows ownership_invalid_mutation warning -->

With $bindable: The child explicitly acknowledges that it may modify the prop:

<!-- Child.svelte -->
<script>
	let { user = $bindable() } = $props()
</script>

<button onclick={() => (user.name = 'Changed')}> Change name </button>
<!-- No warning - mutation is expected -->

When to Bind Objects vs. Primitives

Consider whether you need to bind the entire object or specific properties:

<!-- Binding specific properties (preferred for forms) -->
<UserForm bind:name={user.name} bind:email={user.email} />

<!-- Binding the entire object (useful for complex state) -->
<UserEditor bind:user />

Binding specific properties is more explicit and makes data flow easier to trace. Binding entire objects is appropriate when the child legitimately manages the complete object’s state.


Real-World Use Cases

1. Form Input Components

The most common use case—creating reusable input wrappers:

<!-- FormField.svelte -->
<script>
	let { value = $bindable(''), label, type = 'text', error = null, required = false } = $props()

	let inputId = $props.id()
</script>

<div class="form-field" class:has-error={error}>
	<label for={inputId}>
		{label}
		{#if required}<span class="required">*</span>{/if}
	</label>

	<input
		id={inputId}
		{type}
		bind:value
		class:invalid={error}
		aria-describedby={error ? `${inputId}-error` : undefined}
	/>

	{#if error}
		<span id="{inputId}-error" class="error-message">{error}</span>
	{/if}
</div>

<style>
	.form-field {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
		margin-bottom: 1rem;
	}

	.required {
		color: #dc2626;
	}

	.invalid {
		border-color: #dc2626;
	}

	.error-message {
		color: #dc2626;
		font-size: 0.875rem;
	}
</style>
<!-- RegistrationForm.svelte -->
<script>
	import FormField from './FormField.svelte'

	let formData = $state({
		username: '',
		email: '',
		password: ''
	})

	let errors = $state({})

	function validate() {
		errors = {}

		if (formData.username.length < 3) {
			errors.username = 'Username must be at least 3 characters'
		}

		if (!formData.email.includes('@')) {
			errors.email = 'Please enter a valid email'
		}

		if (formData.password.length < 8) {
			errors.password = 'Password must be at least 8 characters'
		}

		return Object.keys(errors).length === 0
	}

	function handleSubmit() {
		if (validate()) {
			console.log('Form submitted:', formData)
		}
	}
</script>

<form
	onsubmit={(e) => {
		e.preventDefault()
		handleSubmit(e)
	}}
>
	<FormField label="Username" bind:value={formData.username} error={errors.username} required />

	<FormField label="Email" type="email" bind:value={formData.email} error={errors.email} required />

	<FormField
		label="Password"
		type="password"
		bind:value={formData.password}
		error={errors.password}
		required
	/>

	<button type="submit">Register</button>
</form>

2. Modal and Dialog Components

Managing open/closed state from either parent or child:

<!-- Modal.svelte -->
<script>
	let { open = $bindable(false), title, children } = $props()

	function close() {
		open = false
	}

	function handleBackdropClick(event) {
		if (event.target === event.currentTarget) {
			close()
		}
	}

	function handleKeydown(event) {
		if (event.key === 'Escape') {
			close()
		}
	}
</script>

{#if open}
	<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
	<div
		class="modal-backdrop"
		onclick={handleBackdropClick}
		onkeydown={handleKeydown}
		role="dialog"
		aria-modal="true"
		aria-labelledby="modal-title"
	>
		<div class="modal-content">
			<header>
				<h2 id="modal-title">{title}</h2>
				<button class="close-button" onclick={close} aria-label="Close"> × </button>
			</header>
			<div class="modal-body">
				{@render children?.()}
			</div>
		</div>
	</div>
{/if}

<style>
	.modal-backdrop {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
		z-index: 1000;
	}

	.modal-content {
		background: white;
		border-radius: 8px;
		max-width: 500px;
		width: 90%;
		max-height: 90vh;
		overflow: auto;
	}

	header {
		display: flex;
		justify-content: space-between;
		align-items: center;
		padding: 1rem;
		border-bottom: 1px solid #e5e7eb;
	}

	.close-button {
		background: none;
		border: none;
		font-size: 1.5rem;
		cursor: pointer;
		padding: 0.25rem;
	}

	.modal-body {
		padding: 1rem;
	}
</style>
<!-- App.svelte -->
<script>
	import Modal from './Modal.svelte'

	let showSettings = $state(false)
</script>

<button onclick={() => (showSettings = true)}>Open Settings</button>

<Modal bind:open={showSettings} title="Settings">
	<p>Configure your preferences here.</p>
	<button onclick={() => (showSettings = false)}>Save & Close</button>
</Modal>

The modal can be closed by the parent (setting showSettings = false), by clicking the backdrop, pressing Escape, or clicking the close button—all through the same bound state.

3. Accordion/Collapsible Panels

Managing which panel is expanded:

<!-- Accordion.svelte -->
<script>
	let { items, activeIndex = $bindable(null) } = $props()

	function toggle(index) {
		activeIndex = activeIndex === index ? null : index
	}
</script>

<div class="accordion">
	{#each items as item, index (item.id)}
		{@const isActive = activeIndex === index}

		<div class="accordion-item" class:active={isActive}>
			<button class="accordion-header" onclick={() => toggle(index)} aria-expanded={isActive}>
				{item.title}
				<span class="icon">{isActive ? '' : '+'}</span>
			</button>

			{#if isActive}
				<div class="accordion-content">
					{item.content}
				</div>
			{/if}
		</div>
	{/each}
</div>

<style>
	.accordion {
		border: 1px solid #e5e7eb;
		border-radius: 8px;
		overflow: hidden;
	}

	.accordion-item {
		border-bottom: 1px solid #e5e7eb;
	}

	.accordion-item:last-child {
		border-bottom: none;
	}

	.accordion-header {
		width: 100%;
		padding: 1rem;
		background: #f9fafb;
		border: none;
		cursor: pointer;
		display: flex;
		justify-content: space-between;
		align-items: center;
		font-size: 1rem;
		text-align: left;
	}

	.accordion-header:hover {
		background: #f3f4f6;
	}

	.accordion-content {
		padding: 1rem;
	}

	.active .accordion-header {
		background: #e5e7eb;
	}
</style>
<!-- App.svelte -->
<script>
	import Accordion from './Accordion.svelte'

	let expandedPanel = $state(0) // Start with first panel open

	const faqItems = [
		{ id: 1, title: 'What is Svelte?', content: 'Svelte is a compiler...' },
		{ id: 2, title: 'What are runes?', content: 'Runes are special symbols...' },
		{ id: 3, title: 'How does $bindable work?', content: 'The $bindable rune...' }
	]

	function expandAll() {
		// Could implement multi-select with array if needed
	}

	function collapseAll() {
		expandedPanel = null
	}
</script>

<div class="controls">
	<button onclick={collapseAll}>Collapse All</button>
	<span>Currently expanded: {expandedPanel ?? 'None'}</span>
</div>

<Accordion items={faqItems} bind:activeIndex={expandedPanel} />

4. Pagination Components

Synchronizing page state between parent and child:

<!-- Pagination.svelte -->
<script>
	let { currentPage = $bindable(1), totalPages, siblingCount = 1 } = $props()

	function getPageNumbers() {
		const pages = []
		const leftSibling = Math.max(currentPage - siblingCount, 1)
		const rightSibling = Math.min(currentPage + siblingCount, totalPages)

		const showLeftDots = leftSibling > 2
		const showRightDots = rightSibling < totalPages - 1

		if (!showLeftDots && showRightDots) {
			for (let i = 1; i <= 3 + 2 * siblingCount; i++) {
				if (i <= totalPages) pages.push(i)
			}
			pages.push('...')
			pages.push(totalPages)
		} else if (showLeftDots && !showRightDots) {
			pages.push(1)
			pages.push('...')
			for (let i = totalPages - (2 + 2 * siblingCount); i <= totalPages; i++) {
				if (i > 0) pages.push(i)
			}
		} else if (showLeftDots && showRightDots) {
			pages.push(1)
			pages.push('...')
			for (let i = leftSibling; i <= rightSibling; i++) {
				pages.push(i)
			}
			pages.push('...')
			pages.push(totalPages)
		} else {
			for (let i = 1; i <= totalPages; i++) {
				pages.push(i)
			}
		}

		return pages
	}

	let pageNumbers = $derived(getPageNumbers())

	function goToPage(page) {
		if (typeof page === 'number' && page >= 1 && page <= totalPages) {
			currentPage = page
		}
	}
</script>

<nav class="pagination" aria-label="Pagination">
	<button
		onclick={() => goToPage(currentPage - 1)}
		disabled={currentPage === 1}
		aria-label="Previous page"
	>

	</button>

	{#each pageNumbers as page}
		{#if page === '...'}
			<span class="ellipsis">...</span>
		{:else}
			<button
				onclick={() => goToPage(page)}
				class:active={page === currentPage}
				aria-current={page === currentPage ? 'page' : undefined}
			>
				{page}
			</button>
		{/if}
	{/each}

	<button
		onclick={() => goToPage(currentPage + 1)}
		disabled={currentPage === totalPages}
		aria-label="Next page"
	>

	</button>
</nav>

<style>
	.pagination {
		display: flex;
		gap: 0.25rem;
		align-items: center;
	}

	button {
		padding: 0.5rem 0.75rem;
		border: 1px solid #e5e7eb;
		background: white;
		cursor: pointer;
		border-radius: 4px;
	}

	button:hover:not(:disabled) {
		background: #f3f4f6;
	}

	button:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	button.active {
		background: #3b82f6;
		color: white;
		border-color: #3b82f6;
	}

	.ellipsis {
		padding: 0.5rem;
	}
</style>
<!-- DataTable.svelte -->
<script>
	import Pagination from './Pagination.svelte'

	let { data } = $props()

	let page = $state(1)
	let pageSize = 10

	let totalPages = $derived(Math.ceil(data.length / pageSize))
	let paginatedData = $derived(data.slice((page - 1) * pageSize, page * pageSize))

	// Reset to page 1 when data changes significantly
	$effect(() => {
		if (page > totalPages && totalPages > 0) {
			page = totalPages
		}
	})
</script>

<table>
	<thead>
		<tr>
			<th>ID</th>
			<th>Name</th>
			<th>Email</th>
		</tr>
	</thead>
	<tbody>
		{#each paginatedData as item (item.id)}
			<tr>
				<td>{item.id}</td>
				<td>{item.name}</td>
				<td>{item.email}</td>
			</tr>
		{/each}
	</tbody>
</table>

<Pagination bind:currentPage={page} {totalPages} />

<p>Showing page {page} of {totalPages}</p>

5. Tab Components

Managing active tab state:

<!-- Tabs.svelte -->
<script>
	let { tabs, activeTab = $bindable(tabs[0]?.id), children } = $props()
</script>

<div class="tabs-container">
	<div class="tab-list" role="tablist">
		{#each tabs as tab (tab.id)}
			<button
				role="tab"
				aria-selected={activeTab === tab.id}
				aria-controls="panel-{tab.id}"
				class:active={activeTab === tab.id}
				onclick={() => (activeTab = tab.id)}
			>
				{tab.label}
			</button>
		{/each}
	</div>

	<div class="tab-panels">
		{@render children?.(activeTab)}
	</div>
</div>

<style>
	.tabs-container {
		border: 1px solid #e5e7eb;
		border-radius: 8px;
		overflow: hidden;
	}

	.tab-list {
		display: flex;
		background: #f9fafb;
		border-bottom: 1px solid #e5e7eb;
	}

	.tab-list button {
		padding: 0.75rem 1.5rem;
		border: none;
		background: transparent;
		cursor: pointer;
		font-size: 1rem;
		border-bottom: 2px solid transparent;
		margin-bottom: -1px;
	}

	.tab-list button:hover {
		background: #f3f4f6;
	}

	.tab-list button.active {
		border-bottom-color: #3b82f6;
		background: white;
	}

	.tab-panels {
		padding: 1rem;
	}
</style>
<!-- App.svelte -->
<script>
	import Tabs from './Tabs.svelte'

	let currentTab = $state('profile')

	const tabs = [
		{ id: 'profile', label: 'Profile' },
		{ id: 'settings', label: 'Settings' },
		{ id: 'notifications', label: 'Notifications' }
	]

	function goToSettings() {
		currentTab = 'settings'
	}
</script>

<Tabs {tabs} bind:activeTab={currentTab}>
	{#snippet children(active)}
		{#if active === 'profile'}
			<div id="panel-profile" role="tabpanel">
				<h3>Profile</h3>
				<p>Your profile information here.</p>
				<button onclick={goToSettings}>Go to Settings</button>
			</div>
		{:else if active === 'settings'}
			<div id="panel-settings" role="tabpanel">
				<h3>Settings</h3>
				<p>Configure your account settings.</p>
			</div>
		{:else if active === 'notifications'}
			<div id="panel-notifications" role="tabpanel">
				<h3>Notifications</h3>
				<p>Manage your notification preferences.</p>
			</div>
		{/if}
	{/snippet}
</Tabs>

Advanced Patterns

Multiple Bindable Props

Components can have multiple bindable props for complex interactions:

<!-- DateRangePicker.svelte -->
<script>
	let {
		startDate = $bindable(null),
		endDate = $bindable(null),
		minDate = null,
		maxDate = null
	} = $props()

	function handleStartChange(event) {
		startDate = event.target.value || null

		// Ensure end date is not before start date
		if (startDate && endDate && startDate > endDate) {
			endDate = startDate
		}
	}

	function handleEndChange(event) {
		endDate = event.target.value || null

		// Ensure start date is not after end date
		if (startDate && endDate && endDate < startDate) {
			startDate = endDate
		}
	}
</script>

<div class="date-range-picker">
	<div class="field">
		<label for="start-date">Start Date</label>
		<input
			id="start-date"
			type="date"
			value={startDate ?? ''}
			min={minDate}
			max={endDate ?? maxDate}
			onchange={handleStartChange}
		/>
	</div>

	<span class="separator">to</span>

	<div class="field">
		<label for="end-date">End Date</label>
		<input
			id="end-date"
			type="date"
			value={endDate ?? ''}
			min={startDate ?? minDate}
			max={maxDate}
			onchange={handleEndChange}
		/>
	</div>
</div>

<style>
	.date-range-picker {
		display: flex;
		align-items: end;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	.separator {
		padding-bottom: 0.5rem;
		color: #6b7280;
	}

	input {
		padding: 0.5rem;
		border: 1px solid #e5e7eb;
		border-radius: 4px;
	}
</style>
<!-- ReportFilter.svelte -->
<script>
	import DateRangePicker from './DateRangePicker.svelte'

	let filters = $state({
		startDate: '2025-01-01',
		endDate: '2025-01-31'
	})

	function applyFilter() {
		console.log('Filtering from', filters.startDate, 'to', filters.endDate)
	}
</script>

<DateRangePicker bind:startDate={filters.startDate} bind:endDate={filters.endDate} />

<button onclick={applyFilter}>Apply Filter</button>

Computed Bindable Values: Currency Input

A common pattern is transforming values as they flow in and out. This currency input stores cents internally (avoiding floating-point issues) but displays formatted dollars:

<!-- CurrencyInput.svelte -->
<script>
	let {
		// Bind cents (integer) to avoid floating-point precision issues
		cents = $bindable(0),
		currency = 'USD',
		locale = 'en-US'
	} = $props()

	let inputRef = $state(null)
	let isFocused = $state(false)

	// Format for display when not focused
	let displayValue = $derived(
		new Intl.NumberFormat(locale, {
			style: 'currency',
			currency
		}).format(cents / 100)
	)

	// Raw numeric value for editing
	let editValue = $derived((cents / 100).toFixed(2))

	function handleInput(event) {
		const raw = event.target.value.replace(/[^0-9.]/g, '')
		const dollars = parseFloat(raw) || 0
		cents = Math.round(dollars * 100)
	}

	function handleFocus() {
		isFocused = true
		// Select all text for easy replacement
		setTimeout(() => inputRef?.select(), 0)
	}

	function handleBlur() {
		isFocused = false
	}
</script>

<div class="currency-input">
	<input
		bind:this={inputRef}
		type="text"
		inputmode="decimal"
		value={isFocused ? editValue : displayValue}
		oninput={handleInput}
		onfocus={handleFocus}
		onblur={handleBlur}
	/>
</div>

<style>
	.currency-input input {
		padding: 0.5rem;
		font-size: 1rem;
		text-align: right;
		border: 1px solid #e5e7eb;
		border-radius: 4px;
		width: 150px;
	}

	.currency-input input:focus {
		outline: 2px solid #3b82f6;
		outline-offset: -1px;
	}
</style>
<!-- App.svelte -->
<script>
	import CurrencyInput from './CurrencyInput.svelte'

	// Store price in cents for precision
	let priceInCents = $state(1999) // $19.99
</script>

<CurrencyInput bind:cents={priceInCents} />
<p>Stored value: {priceInCents} cents (${(priceInCents / 100).toFixed(2)})</p>

The parent works with cents (integers), while users see and edit formatted currency. This pattern avoids floating-point precision issues common in financial applications.

Computed Bindable Values: Slug Generator

Another practical transformation pattern—auto-generating URL slugs from titles while allowing manual override:

<!-- SlugInput.svelte -->
<script>
	let { title = $bindable(''), slug = $bindable('') } = $props()

	let manuallyEdited = $state(false)

	function generateSlug(text) {
		return text
			.toLowerCase()
			.trim()
			.replace(/[^a-z0-9\s-]/g, '')
			.replace(/\s+/g, '-')
			.replace(/-+/g, '-')
			.replace(/^-|-$/g, '')
	}

	// Auto-generate slug from title unless manually edited
	$effect(() => {
		if (!manuallyEdited && title) {
			slug = generateSlug(title)
		}
	})

	function handleSlugInput(event) {
		manuallyEdited = true
		slug = generateSlug(event.target.value)
	}

	function resetToAuto() {
		manuallyEdited = false
		slug = generateSlug(title)
	}
</script>

<div class="slug-input">
	<div class="field">
		<label for="title">Title</label>
		<input id="title" type="text" bind:value={title} placeholder="Enter post title..." />
	</div>

	<div class="field">
		<label for="slug">
			Slug
			{#if manuallyEdited}
				<button type="button" class="reset-btn" onclick={resetToAuto}> ↺ Auto </button>
			{/if}
		</label>
		<div class="slug-preview">
			<span class="prefix">/blog/</span>
			<input id="slug" type="text" value={slug} oninput={handleSlugInput} placeholder="url-slug" />
		</div>
	</div>
</div>

<style>
	.slug-input {
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.field {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	label {
		font-weight: 500;
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}

	.reset-btn {
		font-size: 0.75rem;
		padding: 0.125rem 0.375rem;
		background: #e5e7eb;
		border: none;
		border-radius: 4px;
		cursor: pointer;
	}

	.slug-preview {
		display: flex;
		align-items: center;
		border: 1px solid #e5e7eb;
		border-radius: 4px;
		overflow: hidden;
	}

	.prefix {
		padding: 0.5rem;
		background: #f3f4f6;
		color: #6b7280;
		font-family: monospace;
	}

	.slug-preview input {
		border: none;
		padding: 0.5rem;
		flex: 1;
		font-family: monospace;
	}

	.slug-preview input:focus {
		outline: none;
	}

	input {
		padding: 0.5rem;
		border: 1px solid #e5e7eb;
		border-radius: 4px;
	}
</style>
<!-- PostEditor.svelte -->
<script>
	import SlugInput from './SlugInput.svelte'

	let post = $state({
		title: '',
		slug: '',
		content: ''
	})
</script>

<SlugInput bind:title={post.title} bind:slug={post.slug} />
<p>Final URL: /blog/{post.slug || '...'}</p>

This pattern is essential for CMS and blog builders—the slug auto-generates from the title but can be manually customized when needed.

Bindable with Validation

Combine binding with validation for controlled inputs:

<!-- ValidatedInput.svelte -->
<script>
	let { value = $bindable(''), validate = () => null, transform = (v) => v } = $props()

	let error = $state(null)
	let touched = $state(false)

	function handleInput(event) {
		const rawValue = event.target.value
		const transformedValue = transform(rawValue)

		// Update the bound value
		value = transformedValue

		// Validate if touched
		if (touched) {
			error = validate(transformedValue)
		}
	}

	function handleBlur() {
		touched = true
		error = validate(value)
	}
</script>

<div class="validated-input" class:has-error={error && touched}>
	<input
		type="text"
		{value}
		oninput={handleInput}
		onblur={handleBlur}
		aria-invalid={error && touched}
	/>

	{#if error && touched}
		<span class="error">{error}</span>
	{/if}
</div>

<style>
	.validated-input {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	input {
		padding: 0.5rem;
		border: 1px solid #e5e7eb;
		border-radius: 4px;
	}

	.has-error input {
		border-color: #dc2626;
	}

	.error {
		color: #dc2626;
		font-size: 0.875rem;
	}
</style>
<!-- App.svelte -->
<script>
	import ValidatedInput from './ValidatedInput.svelte'

	let email = $state('')

	function validateEmail(value) {
		if (!value) return 'Email is required'
		if (!value.includes('@')) return 'Please enter a valid email'
		return null
	}

	function toLowerCase(value) {
		return value.toLowerCase()
	}
</script>

<ValidatedInput bind:value={email} validate={validateEmail} transform={toLowerCase} />

<p>Email: {email}</p>

TypeScript Integration

Properly typing bindable props ensures type safety across component boundaries:

<!-- TypedInput.svelte -->
<script lang="ts">
	interface Props {
		value: string
		placeholder?: string
		disabled?: boolean
	}

	let { value = $bindable(''), placeholder = '', disabled = false }: Props = $props()
</script>

<input type="text" bind:value {placeholder} {disabled} />

For complex types:

<!-- UserEditor.svelte -->
<script lang="ts">
	interface User {
		id: number
		name: string
		email: string
		role: 'admin' | 'user' | 'guest'
	}

	interface Props {
		user: User
		onSave?: (user: User) => void
	}

	let { user = $bindable(), onSave }: Props = $props()

	function handleSave() {
		onSave?.(user)
	}
</script>

<form onsubmit|preventDefault={handleSave}>
	<input bind:value={user.name} placeholder="Name" />
	<input bind:value={user.email} type="email" placeholder="Email" />
	<select bind:value={user.role}>
		<option value="admin">Admin</option>
		<option value="user">User</option>
		<option value="guest">Guest</option>
	</select>
	<button type="submit">Save</button>
</form>

Common Pitfalls and Solutions

1: Forgetting $bindable in the Child

<!-- WRONG: Missing $bindable -->
<script>
	let { value } = $props()
</script>

<input bind:value />

<!-- Parent tries to bind -->
<Child bind:value={parentValue} />
<!-- Works but changes don't flow back up! -->
<!-- CORRECT: Use $bindable -->
<script>
	let { value = $bindable() } = $props()
</script>

<input bind:value />

2: Binding Undefined Values

<!-- WRONG: Binding to potentially undefined -->
<script>
	let maybeValue = $state(undefined)
</script>

<Child bind:value={maybeValue} />
<!-- Runtime error if Child has a fallback! -->
<!-- CORRECT: Initialize with a valid value -->
<script>
	let value = $state('')
</script>

<Child bind:value />

3: Overusing Bindings

Too Many Bindings Break Data Flow Clarity

When a component has many bound props, it becomes difficult to trace where state changes originate. As a guideline: bind at most one or two values per component. If you find yourself binding more, consider whether a single structured callback prop would be clearer.

<!-- ANTIPATTERN: Everything is bound -->
<ComplexForm
	bind:firstName={form.firstName}
	bind:lastName={form.lastName}
	bind:email={form.email}
	bind:phone={form.phone}
	bind:address={form.address}
	bind:city={form.city}
	bind:country={form.country}
	bind:errors
	bind:isValid
	bind:isDirty
	bind:isSubmitting
/>

When too many things are bound, data flow becomes unpredictable. Consider:

<!-- BETTER: Minimal binding with callbacks for actions -->
<ComplexForm
	{initialValues}
	onchange={(values) => (form = values)}
	onsubmit={handleSubmit}
	onerror={handleError}
/>

4: Mutating Non-Bindable Props

<!-- WRONG: Mutating a prop that isn't bindable -->
<script>
	let { items } = $props() // Not $bindable
</script>

<button onclick={() => items.push({ id: Date.now() })}> Add Item </button>
<!-- Causes ownership warning, confusing behavior -->
<!-- CORRECT: Use callback or make bindable -->
<script>
	let { items, onAddItem } = $props()
</script>

<button onclick={() => onAddItem({ id: Date.now() })}>
	Add Item
</button>

<!-- OR make it bindable if appropriate -->
<script>
	let { items = $bindable([]) } = $props()
</script>

5: Circular Updates

Bindable + $effect Can Create Infinite Loops

If an $effect reads and then writes a $bindable prop, it will trigger itself again on the next update. Guard against this by comparing the new value to the current one before writing, or restructure so the logic belongs in a callback prop instead.

<!-- DANGEROUS: Can cause infinite loops -->
<script>
	let { value = $bindable(0) } = $props()

	$effect(() => {
		// This runs when value changes
		value = value + 1 // Causes another change!
	})
</script>
<!-- CORRECT: Guard against circular updates -->
<script>
	let { value = $bindable(0) } = $props()
	let previousValue = value

	$effect(() => {
		if (value !== previousValue) {
			// Process change
			previousValue = value
		}
	})
</script>

When to Use $bindable vs. Callback Props

Use $bindable When:

  1. The prop represents UI state that both parent and child need to read and write (open/closed, selected value, current page)

  2. The binding is intuitive and matches how native elements work (<input bind:value>)

  3. The relationship is simple — one value, bidirectional sync

  4. You’re building form components that wrap native inputs

Use Callback Props When:

  1. The action has side effects beyond updating state (API calls, analytics, validation)

  2. The parent needs to intercept or transform the change before applying it

  3. Multiple values change together and should be handled atomically

  4. You need to prevent or cancel certain updates conditionally

<!-- Good use of $bindable -->
<SearchInput bind:query />
<Modal bind:open />
<Slider bind:value />
<Select bind:selected />

<!-- Better with callbacks -->
<FileUploader onupload={handleFileUpload} />
<Form onsubmit={handleSubmit} onchange={handleFormChange} />
<DataGrid onrowselect={handleRowSelect} onsort={handleSort} />

Best Practices Summary

  1. Use $bindable sparingly — Default to one-way data flow; use binding only when it genuinely simplifies your code.

  2. Always provide fallback values for optional bindable props to ensure predictable behavior.

  3. Document bindable props clearly — Make it obvious which props support binding.

  4. Validate at boundaries — Don’t assume bound values are always valid; validate in both parent and child when necessary.

  5. Prefer primitive bindings — Binding individual values is clearer than binding entire objects.

  6. Consider accessibility — Ensure bound UI state changes are announced to screen readers when appropriate.

  7. Test both bound and unbound usage — Your component should work correctly whether the parent binds or not.

  8. Avoid deep binding chains — If you find yourself binding through multiple component layers, reconsider your architecture.


Quick Reference

<!-- Basic bindable prop -->
<script>
	let { value = $bindable() } = $props()
</script>

<!-- With fallback value -->
<script>
	let { checked = $bindable(false) } = $props()
</script>

<!-- Multiple bindable props -->
<script>
	let {
		min = $bindable(0),
		max = $bindable(100),
		value = $bindable(50)
	} = $props()
</script>

<!-- TypeScript typed -->
<script lang="ts">
	interface Props {
		value: string
		count: number
	}

	let {
		value = $bindable(''),
		count = $bindable(0)
	}: Props = $props()
</script>

<!-- Parent usage -->
<Component bind:value={localValue} />
<Component bind:value />  <!-- shorthand when names match -->
<Component value={readOnlyValue} />  <!-- one-way, no binding -->

The $bindable rune represents Svelte’s thoughtful approach to component communication — acknowledging that while unidirectional data flow is the ideal default, controlled bidirectional binding has legitimate use cases that shouldn’t require verbose workarounds. Use it judiciously, and it will make your component APIs cleaner and more intuitive. Overuse it, and you’ll find yourself debugging unpredictable state changes across your application.


Key Takeaways

  • $bindable is a controlled escape hatch from unidirectional data flow — it’s not the default pattern and should be used sparingly only when it genuinely simplifies code.
  • Marking a prop as $bindable makes it capable of binding, not required — the parent chooses whether to bind (bind:value={x}) or pass one-way (value={x}); the same component works both ways.
  • Fallback values apply only when no prop is passed or the value is undefined — bound props that receive undefined throw runtime errors, while unbound props gracefully use the fallback.
  • Use $bindable for UI state, callbacks for actions — bindable is ideal for open/closed, selected value, current page; callbacks are better for actions with side effects like API calls or validation.
  • Advanced patterns include computed transformations — currency inputs storing cents but displaying dollars, slug generators auto-creating URLs from titles, and validation combining binding with error handling.
  • Common pitfalls to avoid — forgetting $bindable in the child (changes won’t flow up), binding undefined values, overusing bindings causing unpredictable data flow, and creating circular update loops with $effect.

See Also

Official Documentation

External Resources