Skip to content

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.

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:

src/lib/BindableChild.svelte
<script lang="ts">
let { value }: { value: string } = $props();
</script>
<input value={value} />
src/lib/BindableParent.svelte
<!-- @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.json
src/lib/BindableParent.svelte:4:15
Cannot use 'bind:' with this property. It is declared as non-bindable inside
the 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:

src/lib/BindableChildFixed.svelte
<script lang="ts">
let { value = $bindable() }: { value: string } = $props();
</script>
<input bind:value={value} />
src/lib/BindableParentFixed.svelte
<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.

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:

src/lib/FunctionBinding.svelte
<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:

src/lib/function-binding.test.ts
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.

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"]
Where a bind: target comes from

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.

ChoiceBenefitCost
Plain one-way propSimple, predictable data flow — a parent’s re-render is the only way the prop changesChild must emit an event/callback to ever influence the parent
$bindable() propParent and child share one piece of state with no boilerplate event wiringAny caller can mutate the parent’s state through the child — harder to trace where a change came from
bind:value={get, set} function bindingValidation/transformation lives at the binding site, not scattered across oninput handlersMore to read at the call site than a plain bind:value={x}
bind:thisDirect, typed access to a DOM node or component instanceOnly available after mount — reading it during render is a bug, same as any ref-like escape hatch
  • “Every component prop can be bind:-ed, like an element’s value.” No — only props the component itself opts in with $bindable(). Proven above: svelte-check rejects bind: to a plain prop with a message naming the exact fix.
  • “A function binding’s set only runs once, at the initial value.” It runs on every write — every keystroke, in the input example above — same as a plain bind:value’s implicit setter would.
  • “bind:this is available immediately, since it’s declared right next to the element.” It’s undefined/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.

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 declaring value = $bindable() in its own $props()?
  • Is a bind:this reference 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-rolled bind:value + a separate $effect that “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:group does not work across component boundaries?

🐛 Spot the bug

Child.svelte
<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 inside
the 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.

What must a child component declare before a parent can bind: to one of its props?
What happens when this is type-checked?
<!-- Child.svelte -->
<script>
  let { count } = $props();
</script>

<!-- Parent.svelte -->
<script>
  import Child from './Child.svelte';
  let n = $state(0);
</script>
<Child bind:count={n} />
Since which Svelte version can bind:value take a {get, set} function pair instead of a plain variable?
What does the set function in bind:value={() => value, (v) => value = v.toUpperCase()} do?
When is a bind:this reference safe to read?
Why is bind: on a plain DOM element (like <input bind:value>) available with no setup, while bind: on a component requires $bindable()?