Bindings
Two-way binding is sugar over value + event
Section titled “Two-way binding is sugar over value + event”Normally data flows one way: state → the DOM. A binding adds the reverse edge, so a form element also writes back to state. bind:value is shorthand for “set the input’s value from this state, and update this state on input.”
<script> let name = $state('');</script>
<input bind:value={name} /><p>Hello {name}</p>
<!-- Equivalent, written by hand:<input value={name} oninput={(e) => name = e.currentTarget.value} /> -->The binding keeps the two in sync automatically. It works on any input-like element: bind:value for text/number/select, bind:checked for checkboxes, bind:files for file inputs.
Groups: bind:group
Section titled “Groups: bind:group”Radio buttons and checkbox groups that share one piece of state use bind:group — Svelte wires the whole set to a single variable.
<script> let flavor = $state('vanilla'); // radios: a single value let toppings = $state([]); // checkboxes: an array</script>
<label><input type="radio" bind:group={flavor} value="vanilla" /> Vanilla</label><label><input type="radio" bind:group={flavor} value="chocolate" /> Chocolate</label>
<label><input type="checkbox" bind:group={toppings} value="nuts" /> Nuts</label><label><input type="checkbox" bind:group={toppings} value="cherry" /> Cherry</label>For radios, flavor holds the selected value; for checkboxes, toppings is an array of the checked values.
bind:this for element and component references
Section titled “bind:this for element and component references”To get a reference to the actual DOM node (or a component instance), use bind:this. It’s set after the element mounts — read it in an effect or event handler, not during initial render.
<script> let canvas = $state(); // will hold the DOM node
$effect(() => { const ctx = canvas.getContext('2d'); // safe: runs after mount ctx.fillRect(0, 0, 50, 50); });</script>
<canvas bind:this={canvas} width="200" height="200"></canvas>$bindable() is required before a parent can bind: to a prop
Section titled “$bindable() is required before a parent can bind: to a prop”A parent can only bind: to a child component’s prop when the child opts that specific prop into it with $bindable() (available since the $props() rune itself). Without it, bind: on that prop is a real, caught error — not a silent no-op:
<script lang="ts"> let { value }: { value: string } = $props();</script><input value={value} /><!-- @expect-error bind: to a prop the child never declared $bindable() --><script lang="ts"> import BindableChild from './BindableChild.svelte'; let text = $state('hi');</script><BindableChild bind:value={text} />svelte-check catches this at the type level, with a message that names the exact fix:
$ svelte-check --tsconfig ./tsconfig.jsonsrc/lib/BindableParent.svelte:4:15Cannot use 'bind:' with this property. It is declared as non-bindable insidethe component.To mark a property as bindable: 'let { value = $bindable() } = $props()'The fix is exactly what the message says — declare the prop with $bindable() on the child, then the same bind: on the parent compiles clean:
<script lang="ts"> let { value = $bindable() }: { value: string } = $props();</script><input bind:value={value} /><script lang="ts"> import BindableChildFixed from './BindableChildFixed.svelte'; let text = $state('hi');</script><BindableChildFixed bind:value={text} />That two-way flow across the component boundary is powerful but can obscure data flow if overused — props-down/events-up is often clearer for anything beyond simple form controls, which is why $bindable() is opt-in per prop rather than the default.
Function bindings: bind:value={get, set}
Section titled “Function bindings: bind:value={get, set}”Since Svelte 5.9, a binding can take a {get, set} pair of functions instead of a plain identifier, letting you validate or transform the value on the way in:
<script lang="ts"> let value = $state('');</script><input bind:value={() => value, (v) => (value = v.toUpperCase())} /><p data-testid="value">{value}</p>Typing into the field runs every keystroke through the set function before it lands in state — and because the DOM value is driven by the get function reading that same state, the input reflects the transformed value right back:
import { render, fireEvent } from '@testing-library/svelte';import { expect, test } from 'vitest';import FunctionBinding from './FunctionBinding.svelte';
test('a function binding transforms the value on the way in, and the transformed value reflects back into the input', async () => { const { getByRole, getByTestId } = render(FunctionBinding); const input = getByRole('textbox') as HTMLInputElement; await fireEvent.input(input, { target: { value: 'hello' } }); expect(getByTestId('value').textContent).toBe('HELLO'); expect(input.value).toBe('HELLO');});✓ a function binding transforms the value on the way in, and the transformed value reflects back into the input
Test Files 1 passed (1) Tests 1 passed (1)This is the same mechanism $bindable() itself is built on internally, and it’s how the “derive, don’t sync” idea from the reactivity module extends into bindings: instead of a plain two-way mirror, you get a controlled gate the value passes through in both directions.
bind: on components vs. elements
Section titled “bind: on components vs. elements”On a plain DOM element, bind:value (and friends) are built into Svelte itself — every input-like element supports them without any setup. On a component, bind: only works for props the component explicitly marked $bindable(); there’s no implicit binding surface the way there is for an <input>. That asymmetry is deliberate: a component’s author decides which props are safe to let a caller mutate, the same way a library decides which fields of its public API are read-write versus read-only.
flowchart TB
el["DOM element (input, select, ...)"] --> builtin["bind:value/checked/group/this — built in, no opt-in needed"]
comp["Component"] --> check{"prop declared\nwith $bindable()?"}
check -->|"yes"| ok["bind: works — two-way flow across the boundary"]
check -->|"no"| err["bind: fails svelte-check:\nCannot use 'bind:' with this property"] Mental Model
Section titled “Mental Model”A binding is a contract with an opt-out clause, not a default. On elements, the browser already exposes a two-way-capable surface (value, checked, the DOM node itself), so Svelte just wires it up. On components, there is no such surface until the author creates one — $bindable() is that creation, prop by prop. Treat every $bindable() as a small API decision: “callers may reach into my state here,” which is why a plain, non-bindable prop staying one-way (props-down/events-up) is the sensible default, not an oversight.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Plain one-way prop | Simple, predictable data flow — a parent’s re-render is the only way the prop changes | Child must emit an event/callback to ever influence the parent |
$bindable() prop | Parent and child share one piece of state with no boilerplate event wiring | Any caller can mutate the parent’s state through the child — harder to trace where a change came from |
bind:value={get, set} function binding | Validation/transformation lives at the binding site, not scattered across oninput handlers | More to read at the call site than a plain bind:value={x} |
bind:this | Direct, typed access to a DOM node or component instance | Only available after mount — reading it during render is a bug, same as any ref-like escape hatch |
Common Misconceptions
Section titled “Common Misconceptions”- “Every component prop can be
bind:-ed, like an element’svalue.” No — only props the component itself opts in with$bindable(). Proven above:svelte-checkrejectsbind:to a plain prop with a message naming the exact fix. - “A function binding’s
setonly runs once, at the initial value.” It runs on every write — every keystroke, in the input example above — same as a plainbind:value’s implicit setter would. - “
bind:thisis available immediately, since it’s declared right next to the element.” It’sundefined/empty until after mount; reading it during the component’s initial render (not in an effect or handler) gets you nothing. - “Component bindings and props-down/events-up are interchangeable — pick whichever is shorter to type.” They have different blast radii: a bindable prop lets the child mutate the parent’s state directly, which is harder to trace than an explicit callback prop when something changes unexpectedly.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated component uses bind:, check for these before trusting it:
- Does a component’s template do
<Child bind:value={x} />without the child ever declaringvalue = $bindable()in its own$props()? - Is a
bind:thisreference read during the render body (not inside$effect/an event handler), which would see it as still empty? - Could a function binding (
bind:value={get, set}) replace a hand-rolledbind:value+ a separate$effectthat “corrects” the value after the fact? - Is a component prop marked
$bindable()when the component never actually needs the parent to observe writes — an unnecessary two-way surface that widens the API more than needed? - For
bind:group, are the radio/checkbox inputs actually inside the same component instance —bind:groupdoes not work across component boundaries?
🐛 Spot the bug
<script>let { open } = $props();</script><button onclick={() => (open = !open)}>{open ? 'Close' : 'Open'}</button>
<!-- Parent.svelte --><script>import Child from './Child.svelte';let panelOpen = $state(false);</script><Child bind:open={panelOpen} />{#if panelOpen}<p>Panel content</p>{/if}Show the answer
Child never declares open as $bindable() — it’s a plain destructured prop. svelte-check catches this at the type level before it ever runs:
Cannot use 'bind:' with this property. It is declared as non-bindable insidethe component.To mark a property as bindable: 'let { open = $bindable() } = $props()'The fix is exactly what the message says:
<script> let { open = $bindable() } = $props();</script>Until that’s added, this is a compile-time-caught error, not a silent bug — but the reasoning matters for reading AI-generated code that mixes both patterns inconsistently across a codebase: bind: on a component is only ever valid for props the component’s own author explicitly opted in, never something a caller can force from outside.