LearnAdding interactivity
Forms and inputs
Most interactivity ends in a form: a search box, a settings page, a checkout. You already have the pieces: a box holds the value, the input reads it, and a handler writes it. This page goes through each kind of input, first as the browser's own element and then as its warp control, and ends with submitting.
Someone wants 1 small oat milk coffee.
import { state } from '@weft-js/core'import { Card, Input, NumberField, RadioGroup, Select, Switch, Textarea } from '@weft-js/warp'type Size = 'small' | 'large'export default function Order() { const name = state('') const note = state('') const cups = state<number | null>(1) const size = state<Size>('small') const milk = state<string | null>('oat') const iced = state(false) return ( <Card title="One of each"> <Input label="Name" value={name.value} onInput={e => (name.value = e.currentTarget.value)} /> <Textarea label="Note" value={note.value} onInput={e => (note.value = e.currentTarget.value)} /> {/* NumberField and Select take the box itself and write it. */} <NumberField label="Cups" min={1} max={9} value={cups} /> <Select label="Milk" value={milk} options={[{ value: 'oat', label: 'Oat' }, { value: 'whole', label: 'Whole' }, { value: 'none', label: 'None' }]} /> <RadioGroup label="Size" value={size.value} onChange={v => (size.value = v)} options={[{ value: 'small', label: 'Small' }, { value: 'large', label: 'Large' }]} /> <Switch checked={iced.value} onChange={(e: Event & { currentTarget: HTMLInputElement }) => (iced.value = e.currentTarget.checked)}> Iced </Switch> <p> {name.value || 'Someone'} wants {cups.value ?? 0} {size.value} {iced.value ? 'iced ' : ''} {milk.value === 'none' ? 'black' : `${milk.value ?? ''} milk`} coffee{note.value ? `: “${note.value}”` : '.'} </p> </Card> )}Try this: Change each control and watch the sentence. Every control writes a box, and the sentence reads them all.
A field tied to a box
The pattern for every input is the same two halves: the input shows the box with value, and a handler writes the box when the user changes it.
import { state } from '@weft-js/core'export function Search() { const query = state('') return ( <label> Search <input value={query.value} onInput={e => (query.value = e.currentTarget.value)} /> <p>{query.value ? `Looking for “${query.value}”` : 'Type to search'}</p> </label> )}Now the box always holds what's in the field, so anything else can read it: the paragraph, a derived that filters a list, a button that's only enabled when the field isn't empty. And a write from anywhere else, query.value = '' in a Clear button, shows in the field.
value, checked and selected are written as DOM properties, not attributes. The attribute is only the starting value: after someone types, setting the attribute no longer changes what the field shows, and the property does. weft handles that for you.
In React this is a controlled input. One difference to know: React puts the field back to the state's value after every keystroke, so an input with a value and no handler can't be typed in. weft writes the field only when the box changes. That matters in one case, covered in the pitfall below.
Letting the browser keep the value
Not every field needs a box. If nothing reads the value until the form is submitted, let the field keep it and read it then, with FormData (see Submitting a form, below). Give such a field a starting value with defaultValue, or defaultChecked for a checkbox: those set the initial value and leave the field alone after.
Keep a value in a box when something on the page reacts to it as it changes: a character count, a filtered list, a button that enables, another field that depends on it. Otherwise the browser holding it is fine, and less code.
Each kind of input
The browser's own elements work as below. In an app that uses warp, weft check suggests warp's control instead of a browser-drawn <select>, <textarea>, number input and the like (D005): warp's draw the same in every browser, with the focus ring and touch targets of the rest of the app. Each section shows both.
Text and textarea
value and onInput, as above. e.currentTarget.value is a string. A <textarea> works the same way: in TSX it takes a value like an input, not children.
warp's Input and Textarea take the same two props, plus a label, which they need:
import { state } from '@weft-js/core'import { Input, Textarea } from '@weft-js/warp'export function Note() { const title = state('') const body = state('') return ( <form> <Input label="Title" value={title.value} onInput={e => (title.value = e.currentTarget.value)} /> <Textarea label="Note" rows={4} value={body.value} onInput={e => (body.value = e.currentTarget.value)} /> <p>{body.value.length} characters</p> </form> )}Every other attribute you give them, such as type="email", name, required or autocomplete, goes to the real <input> or <textarea> inside.
Numbers
A field's value is always a string, even for type="number". Convert it as you read it. valueAsNumber gives a number, or NaN when the field is empty or not a number:
import { state } from '@weft-js/core'export function Quantity() { const qty = state(1) return ( <label> Quantity{' '} <input type="number" min={1} value={qty.value} onInput={e => { const n = e.currentTarget.valueAsNumber if (!Number.isNaN(n)) qty.value = n }} /> <p>Total ${qty.value * 12}</p> </label> )}warp's NumberField holds a number | null, with null for empty, and takes the box itself as value. It writes the box as the user types, steps or presses the arrow keys:
import { state } from '@weft-js/core'import { NumberField } from '@weft-js/warp'export function Quantity() { const qty = state<number | null>(1) return ( <section> <NumberField label="Quantity" min={1} max={20} value={qty} /> <p>Total ${(qty.value ?? 0) * 12}</p> </section> )}Slider takes a number and an onChange that receives the new number: value={volume.value} onChange={v => (volume.value = v)}.
Checkboxes and switches
A checkbox's value is checked, a boolean, and onChange fires when it flips:
import { state } from '@weft-js/core'export function Terms() { const agreed = state(false) return ( <label> <input type="checkbox" checked={agreed.value} onChange={e => (agreed.value = e.currentTarget.checked)} /> I agree <p>{agreed.value ? 'Thanks!' : 'Please tick the box.'}</p> </label> )}warp's Checkbox and Switch are a real checkbox inside, and pass checked and onChange to it. Their extra props are typed loosely, so give the handler's event a type:
import { state } from '@weft-js/core'import { Checkbox, Switch } from '@weft-js/warp'type Toggle = Event & { currentTarget: HTMLInputElement }export function Prefs() { const agreed = state(false) const dark = state(false) return ( <section> <Checkbox checked={agreed.value} onChange={(e: Toggle) => (agreed.value = e.currentTarget.checked)}> I agree to the terms </Checkbox> <Switch checked={dark.value} onChange={(e: Toggle) => (dark.value = e.currentTarget.checked)}> Dark mode </Switch> </section> )}Use a switch for a setting that applies at once, and a checkbox for a choice that's submitted with a form.
Radio buttons
Radio buttons that share a name form a group. Each one is checked when the box holds its value:
import { state } from '@weft-js/core'const sizes = ['small', 'medium', 'large'] as constexport function SizePicker() { const size = state<(typeof sizes)[number]>('medium') return ( <fieldset> <legend>Size</legend> {sizes.map(s => ( <label key={s}> <input type="radio" name="size" value={s} checked={size.value === s} onChange={() => (size.value = s)} /> {s} </label> ))} </fieldset> )}warp's RadioGroup does that wiring for you. It takes the current value, an onChange that receives the picked value (not an event), and the options. Segmented takes the same props and draws them as a row of segments:
import { state } from '@weft-js/core'import { RadioGroup } from '@weft-js/warp'type Size = 'small' | 'medium' | 'large'export function SizePicker() { const size = state<Size>('medium') return ( <RadioGroup label="Size" value={size.value} onChange={v => (size.value = v)} options={[{ value: 'small', label: 'Small' }, { value: 'medium', label: 'Medium' }, { value: 'large', label: 'Large' }]} /> )}Select
A <select> takes value and onChange, like a text field:
import { state } from '@weft-js/core'export function Milk() { const milk = state('oat') return ( <label> Milk{' '} <select value={milk.value} onChange={e => (milk.value = e.currentTarget.value)}> <option value="oat">Oat</option> <option value="whole">Whole</option> <option value="none">None</option> </select> </label> )}The compiler writes a select's value after its options exist, so the right option is selected even when the options come from a list.
warp's Select takes options as data and, like NumberField, the box itself as value. A real <select> stays inside it, so a form posts, resets and validates as it would with the browser's:
import { state } from '@weft-js/core'import { Select } from '@weft-js/warp'export function Milk() { const milk = state<string | null>('oat') return ( <section> <Select label="Milk" value={milk} options={[{ value: 'oat', label: 'Oat' }, { value: 'whole', label: 'Whole' }, { value: 'none', label: 'None' }]} /> <p>{milk.value === 'none' ? 'Black' : `With ${milk.value ?? 'no'} milk`}</p> </section> )}Which warp control takes what
| Control | Value | How it writes |
|---|---|---|
Input, Textarea, Search | value={box.value} (a string) | onInput={e => …}, an event |
Checkbox, Switch | checked={box.value} | onChange={e => …}, an event |
RadioGroup, Segmented | value={box.value} | onChange={v => …}, the value |
Slider | value={box.value} (a number, or two) | onChange={v => …}, the value |
Select, NumberField, the date and time fields | value={box}, the box itself | Writes the box. Or value plus onValueChange, or defaultValue |
The last row is warp's Bindable props: a control that takes the box writes it, so there's no handler to write. bind: isn't needed for them, and isn't what they expect: see Writing to a parent with bind:.
Submitting a form
A <form> gives you three things for free: Enter in a field submits it, the browser checks required, type="email", min, pattern and the rest before it does, and a reset button empties it. Handle onSubmit on the form, not onClick on the button, and you get all three:
import { state } from '@weft-js/core'import { Button, Card, Input, Spinner } from '@weft-js/warp'// Stands in for a request to a server.const send = (email: string) => new Promise<string>(done => { setTimeout(() => done(email), 800) })export default function Newsletter() { const sending = state(false) const joined = state<string | null>(null) const join = async (email: string) => { sending.value = true joined.value = await send(email) sending.value = false } const submit = (e: SubmitEvent & { currentTarget: HTMLFormElement }) => { // Without this the browser would post the form and load a new page. e.preventDefault() const email = new FormData(e.currentTarget).get('email') if (typeof email === 'string') void join(email) } return ( <Card title="Newsletter"> {joined.value ? <p>Thanks, {joined.value} is on the list.</p> : ( <form onSubmit={submit}> <Input label="Email" name="email" type="email" required disabled={sending.value} /> <Button type="submit" disabled={sending.value}> {sending.value ? <Spinner /> : 'Join'} </Button> </form> )} </Card> )}Try this: Press Join with the field empty, then with an email. The browser checks the field, and the handler stops the page from reloading.
The handler does three things:
e.preventDefault(). Without it, the browser posts the form to the page's URL and loads the answer as a new page.- Reads the fields with
FormData.new FormData(e.currentTarget)collects every named field in the form. No boxes needed: the browser kept the values. - Holds state for the request,
sendingandjoined, so the page can show progress and the result.
The browser checks required and type="email" before onSubmit runs: press Join with the field empty and the handler never runs. warp's fields show the browser's message in their own error style.
Nothing waits for a promise an event handler returns, and a rejected one goes unhandled. So keep the submit handler itself synchronous, as the demo does: call preventDefault() first, then start the async work with void join(email), and handle failure inside join.
Server forms
A form that saves to your own server doesn't need any of this by hand. With @weft-js/start, <Form of={addTodo}> posts to a server mutation: it validates the input with the mutation's schema, shows errors next to each field, tracks pending, resets after a success, and still works before the page's JavaScript has loaded or with it turned off. Forms and validation covers it.
The browser-side handling on this page is for everything else: a form that filters a list, talks to a third-party API, or never leaves the page.
Recap
- Tie an input to a box with two halves:
value={box.value}to show it, a handler to write it. - weft writes the field only when the box changes. A handler that rejects an edit puts the field back itself.
- Let the browser keep a value, with
defaultValueandFormData, when nothing reads it before submit. - Checkboxes use
checkedandonChange; radios share anameand compare with the box; a select takesvalue. - In a warp app, use warp's controls (D005). Text and choice controls take a value and a handler;
Select,NumberFieldand the date fields take the box itself. - Submit with
onSubmiton the form,e.preventDefault(), andFormData. For server forms, useFormfrom@weft-js/start.
Try it
Challenge 1
A live character limit
Add a counter under this textarea that shows how many of 140 characters are left, turns into an error past the limit, and disables the Post button when the text is empty or too long.
import { state } from '@weft-js/core'import { Button, Textarea } from '@weft-js/warp'export function Compose() { const text = state('') return ( <form> <Textarea label="What's happening?" value={text.value} onInput={e => (text.value = e.currentTarget.value)} /> <Button type="submit">Post</Button> </form> )}Show the solution
import { derived, state } from '@weft-js/core'import { Button, Textarea } from '@weft-js/warp'export function Compose() { const text = state('') const left = derived(() => 140 - text.value.length) return ( <form> <Textarea label="What's happening?" value={text.value} hint={`${left.value} left`} error={left.value < 0 ? `${-left.value} too many` : undefined} onInput={e => (text.value = e.currentTarget.value)} /> <Button type="submit" disabled={!text.value.trim() || left.value < 0}>Post</Button> </form> )}left is worked out from the box, so it can't go stale. The hint, the error and the button all read it.
Challenge 2
Read a form on submit
This sign-up form keeps nothing in state. Make it show "Welcome, <name>!" in place of the form after it's submitted, reading the name from the form itself.
import { Button, Input } from '@weft-js/warp'export function SignUp() { return ( <form> <Input label="Name" name="name" required /> <Button type="submit">Sign up</Button> </form> )}Show the solution
import { state } from '@weft-js/core'import { Button, Input } from '@weft-js/warp'export function SignUp() { const name = state<string | null>(null) const submit = (e: SubmitEvent & { currentTarget: HTMLFormElement }) => { e.preventDefault() const given = new FormData(e.currentTarget).get('name') if (typeof given === 'string') name.value = given } return ( <section> {name.value ? <p>Welcome, {name.value}!</p> : ( <form onSubmit={submit}> <Input label="Name" name="name" required /> <Button type="submit">Sign up</Button> </form> )} </section> )}The field keeps its own value until submit; only the result needs a box. required means the handler never sees an empty name.
Challenge 3
Digits only
Make this field accept digits only: typing a letter should leave the field as it was.
import { state } from '@weft-js/core'import { Input } from '@weft-js/warp'export function Pin() { const pin = state('') return <Input label="PIN" inputmode="numeric" value={pin.value} onInput={e => (pin.value = e.currentTarget.value)} />}Show the solution
import { state } from '@weft-js/core'import { Input } from '@weft-js/warp'export function Pin() { const pin = state('') return ( <Input label="PIN" inputmode="numeric" value={pin.value} onInput={e => { pin.value = e.currentTarget.value.replace(/\D/g, '') e.currentTarget.value = pin.value }} /> )}Stripping the letter leaves the box as it was, so weft has nothing to write to the field. The handler puts the field back to the box itself.