LearnAdding interactivity
Writing to a parent with bind:
On Passing props a child could change its own copy of a prop with let or props.x =, but the parent never saw it: the write stayed in the child until the parent's value next changed. Sometimes the child's change should reach the parent. A stepper changes how many guests are booked; a name field changes the name. To let a write leave the component, the parent passes its box with bind:.
2
Booked for 2 people
import { state } from '@weft-js/core'import { Button, Card } from '@weft-js/warp'function Stepper(props: { value?: number }) { let { value = 0 } = props return ( <p> <Button size="sm" emphasis="outline" onClick={() => value--}>−</Button> <strong> {value} </strong> <Button size="sm" emphasis="outline" onClick={() => value++}>+</Button> </p> )}export default function Guests() { const guests = state(2) return ( <Card title="Table for"> <Stepper bind:value={guests} /> <p>Booked for {guests.value} people</p> </Card> )}Try this: Press + and −. The line under the stepper, which reads the parent's box, follows.
The call site decides
<Stepper bind:value={guests} /> // writes reach `guests`<Stepper value={guests.value} /> // writes stay in StepperStepper is the same in both. It writes value with let:
function Stepper(props: { value?: number }) { let { value = 0 } = props return <Button onClick={() => value++}>{value}</Button>}Only bind: at the call site decides whether that write leaves the component. So reading a parent tells you everything that may change its state: if no call site in the parent says bind:, no child can write its boxes. You never have to open a child to find out whether it writes something it was given.
What bind: passes
A prop normally passes a value: value={guests.value} gives the child a live read of the number. bind:value={guests} gives the child the same live read, plus a way back. The compiler turns it into a prop with a getter and a setter:
{ get value() { return guests.value }, set value(v) { guests.value = v } }Reading value in the child reads the box. Writing it, through let, props.value =, ++ or +=, calls the setter, which writes the box. The child's code doesn't change between the two: what changes is whether the prop has a setter.
Writing in the child
Any of these writes a prop. Without bind:, each holds in the child until the parent's value changes; with it, each writes the parent's box:
| In the child | Parent passed n={x} | Parent passed bind:n={box} |
|---|---|---|
Read n or props.n | Live | Live |
let { n } = props; n = 5 | Holds in the child until the parent's value next changes | Writes box |
let n = props.n; n++ | The same | Writes box |
props.n = 5 | The same | Writes box |
const n = props.n; n = 5 | W001: use let or props.n = | W001 |
Both behaviours are in this demo. The first two fields are bound; the nickname gets value={first.value}, a plain value:
Hello, Ada Lovelace
import { state } from '@weft-js/core'import { Button, Card, Input } from '@weft-js/warp'// A field that edits its own value. It doesn't know or care whether anyone above is listening.function NameField(props: { label: string; value?: string }) { let { value = '' } = props return <Input label={props.label} value={value} onInput={e => (value = e.currentTarget.value)} />}export default function Signup() { const first = state('Ada') const last = state('Lovelace') return ( <Card title="Two bound fields, one that isn't"> <NameField label="First name" bind:value={first} /> <NameField label="Last name" bind:value={last} /> <NameField label="Nickname (not bound)" value={first.value} /> <p>Hello, {first.value} {last.value}</p> <Button size="sm" emphasis="ghost" onClick={() => { first.value = ''; last.value = '' }}>Clear</Button> </Card> )}Try this: Type in First name, then in Nickname, then press Clear. The bound fields reach the parent; the nickname edit stays in its field until the parent changes.
Type in First name: the greeting follows, because the write reached first. Type in Nickname: only the nickname field changes, because its write has nowhere to go. Then press Clear: first changes, and the nickname field takes the parent's value again, dropping your local edit. The parent's value always wins when it changes.
Only a whole prop can be written this way. let n = props.user.name; n = 1 is W001. props.user.name = 1 is plain JavaScript: it changes the user object, which is the mutation Updating objects and arrays warns against.
bind: takes a box, on a component
bind: has two requirements, and breaking either is W061:
- It goes on a component, not on a built-in element.
<input bind:value={name} />is an error. A built-in input takesvalueand anonInput(oronChange) handler, as on Forms and inputs. - It takes a box.
bind:value={guests}orbind:value={cart.note}orbind:value={props.box}, neverbind:value={guests.value}orbind:value={3}.
<input bind:value={name} /> // W061: a built-in element<input value={name.value} onInput={e => (name.value = e.currentTarget.value)} /> // what it should beTypes: weft tsc and bind(box)
TypeScript doesn't know that bind:value means the prop value. To plain tsc, bind:value is an unrelated namespaced attribute, so a component with a required value prop would be reported as missing it.
weft tsc, weft check and the editor plugin know better: they read bind:value={box} as value with the box's value type. A required prop is satisfied, and a box of the wrong type is an error. Run weft tsc in CI rather than tsc, and you can make the prop required:
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'function Stepper(props: { value: number }) { let { value } = props return ( <p> <Button size="sm" emphasis="outline" onClick={() => value--}>−</Button> <strong> {value} </strong> <Button size="sm" emphasis="outline" onClick={() => value++}>+</Button> </p> )}export function Booking() { const guests = state(2) return <Stepper bind:value={guests} />}Where something only runs plain tsc, keep the prop optional, as the demo at the top does, or use the other form. bind from @weft-js/core wraps the box in the attribute's value:
import { bind, state } from '@weft-js/core'import { Button } from '@weft-js/warp'function Stepper(props: { value: number }) { let { value } = props return <Button onClick={() => value++}>{value}</Button>}export function Booking() { const guests = state(2) return <Stepper value={bind(guests)} />}value={bind(guests)} is the same binding as bind:value={guests}: the compiler gives the prop the same getter and setter. To TypeScript, bind(guests) is a call that returns a number, which every tsc accepts. Outside a component's prop, bind(box) is W061 too.
Passing a box instead
You can also pass the box itself and type the prop as State<T>:
import { type State } from '@weft-js/core'import { Input } from '@weft-js/warp'export function Coupon(props: { code: State<string> }) { return <Input label="Coupon" value={props.code.value} onInput={e => (props.code.value = e.currentTarget.value)} />}The parent writes <Coupon code={coupon} />, with no .value. Passing a box gives write access just as bind: does, and it's just as visible at the call site: the parent passes the box itself.
Which to choose:
bind:when the child edits a value. The child stays simple, it reads and writes a plain prop, and it works with or without a parent box: withvalue={x}it keeps its own edits, withbind:value={box}it writes the parent's.- A
State<T>prop when the child needs the box itself: to read one entry with.getin a list row (see Keyed state and entries), to pass it on to a warp control, or to call.seton it.
Mixing them up is W008 in the catalog (this weft doesn't report it yet): a box where the child's prop type wants a value, or a value where it wants a box.
Patterns
A field in a child
The demo above is the most common use: a component that wraps an input, with its own label and checks, edits a value the parent owns. The child writes its own let:
import { state } from '@weft-js/core'import { Input } from '@weft-js/warp'function EmailField(props: { value?: string }) { let { value = '' } = props const ok = () => value === '' || value.includes('@') return ( <Input label="Email" type="email" value={value} error={ok() ? undefined : 'An email has an @ in it'} onInput={e => (value = e.currentTarget.value)} /> )}export function Settings() { const email = state('') return ( <section> <EmailField bind:value={email} /> <p>{email.value ? `Receipts go to ${email.value}` : 'No receipts'}</p> </section> )}warp's controls take the box
warp's own controls that hold a value, such as Select, NumberField and the date and time fields, take the box directly as value. They write it, and you don't need bind::
import { state } from '@weft-js/core'import { NumberField } from '@weft-js/warp'export function Guests() { const guests = state<number | null>(2) return ( <section> <NumberField label="Guests" min={1} value={guests} /> <p>Table for {guests.value ?? 0}</p> </section> )}They also take a plain value with onValueChange, or a defaultValue when you don't need the value at all. Modal takes its open state the same way, as a box. Pass the box: bind:value on these would give them a plain value, which they'd show but never write back. Forms and inputs has each control.
Callbacks still work
You don't have to use bind:. The React pattern, a value and a handler prop, works in weft as it does in React:
import { state } from '@weft-js/core'import { Button } from '@weft-js/warp'function Stepper(props: { value: number; onChange: (n: number) => void }) { return <Button onClick={() => props.onChange(props.value + 1)}>{props.value}</Button>}export function Booking() { const guests = state(2) return <Stepper value={guests.value} onChange={n => (guests.value = n)} />}Use it when the parent wants to decide what a change means: to refuse it, adjust it or do something else as well. When it just wants the value, bind: says that with less code.