Forms
Forms stay plain HTML forms. Name your inputs, read them with FormData on
submit, and let the server validate. Bind a field to state only when the UI has to
react while the user types.
Submitting
<form pp-component="contact_form" onsubmit="send(event)">
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message"></textarea>
<label><input type="checkbox" name="newsletter" value="yes" /> Subscribe</label>
<button disabled="{sending}">Send</button>
<script>
const [sending, startTransition] = pp.transition();
const send = (event) => {
event.preventDefault();
const form = event.currentTarget;
// input names define the payload; your server validates it
const data = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
await pp.rpc("sendMessage", data);
form.reset();
});
};
</script>
</form>
- The
nameattributes define the payload, so there is no per-field state and nopp-refper input. - An unchecked checkbox is absent from
FormData, exactly as in a native submit. The server should treat a missing key asfalse. - To send files, pass the
Fileobjects in the payload. The request switches to multipart on its own; see uploads. - Don't add a submit listener in an effect.
onsubmitin the markup is the binding.
Controlled or uncontrolled
Each control is one or the other for its whole life. Switching logs
[PP-WARN] <input#id> changed from uncontrolled to controlled, which almost always
means state started as undefined. Initialize it (pp.state("")) instead
of adding both attributes.
| Markup | Mode | Use when |
|---|---|---|
value="{state}" + oninput |
Controlled | State owns the value on every render. Use it when the UI reacts to each keystroke (live search, character counters, formatting). |
defaultvalue="{expr}" |
Uncontrolled | Seeds the field once; the user owns it afterwards. Read it with FormData on submit. The cheapest option. |
checked="{state}" + onchange |
Controlled | Checkbox or radio driven by state. |
defaultchecked="{expr}" |
Uncontrolled | Checkbox or radio seeded once. |
<select value="{state}"> |
Controlled | Selected option follows state. For multiple, bind an array. |
<textarea>{text}</textarea> |
Either | See Textareas below. |
What re-renders never take from the user: focus and the text selection in the field being typed into are restored after each patch, and a controlled date or time input that has focus is not overwritten until it loses focus. Otherwise a half-typed date would be replaced mid-edit.
Checkboxes and radios
A controlled checked is applied to the element's property, in both
directions, on every render. That includes rows inside a keyed pp-for. So setting
state to false unticks a box the user ticked, and a box never shows a value
that disagrees with state:
<div pp-component="settings_panel">
<label>
<input type="checkbox" checked="{emails}" onchange="setEmails(target.checked)" />
Email notifications
</label>
<button onclick="setEmails(false)">Turn all off</button> <!-- unticks the box -->
<template pp-for="plan in plans">
<label key="{plan.id}">
<input type="radio" name="plan" value="{plan.id}"
checked="{selectedPlan === plan.id}" onchange="setSelectedPlan(plan.id)" />
{plan.label}
</label>
</template>
<script>
const [emails, setEmails] = pp.state(true);
const [selectedPlan, setSelectedPlan] = pp.state("basic");
const plans = [{ id: "basic", label: "Basic" }, { id: "pro", label: "Pro" }];
</script>
</div>
checked, disabled, selected, hidden,
required, readonly, open and the other boolean attributes are
added for truthy values and removed for falsy ones. Hyphenated names that only
contain one of those words, such as data-hidden,
aria-checked or data-open-state, are ordinary attributes and receive
the text "true" / "false".
Selects
<div pp-component="filters">
<select value="{country}" onchange="setCountry(target.value)">
<option value="">Any country</option>
<template pp-for="c in countries">
<option key="{c.code}" value="{c.code}">{c.name}</option>
</template>
</select>
<!-- multiple: bind an ARRAY of option values -->
<select multiple value="{tags}"
onchange="setTags(Array.from(target.selectedOptions, (o) => o.value))">
<option value="new">New</option>
<option value="sale">On sale</option>
<option value="eco">Eco</option>
</select>
<script>
const [country, setCountry] = pp.state("");
const [tags, setTags] = pp.state(["sale"]);
const countries = pp.props.countries ?? [];
</script>
</div>
- A single
selectmatches itsvalueas a string.nullandundefinedselect thevalue=""option. - A
multipleselect takes an array and selects every option whose value is in it. - An uncontrolled select takes
defaultvalue="{expr}"(an array formultiple). Later user changes are kept across re-renders, andform.reset()returns to the seed.
Textareas
<!-- Uncontrolled: nothing bound. User typing survives every re-render. -->
<textarea name="notes"></textarea>
<!-- Uncontrolled with a seed: -->
<textarea name="bio" defaultvalue="{profile.bio}"></textarea>
<!-- Controlled: content follows state; pair it with oninput -->
<textarea value="{draft}" oninput="setDraft(target.value)"></textarea>
<p>{draft.length} / 280</p>
A textarea's value is its content, so every render writes it. The runtime treats a
textarea as controlled only once its rendered content has changed between
renders, which is what happens when state is bound to it. From then on it follows state
on every render. A textarea whose content never changes (unbound, or
defaultvalue-seeded) is left to the user, so typing survives re-renders
triggered by unrelated state in the same component. The one difference from React:
a value bound to state that never changes is not snapped back while the user
types. Always pair a controlled textarea with oninput.
Resetting
form.reset() (or a type="reset" button) returns uncontrolled fields to
their defaultvalue / defaultchecked seed, including selects. Controlled
fields follow state, so reset them by resetting the state.
Showing server validation errors
Validate in the server function and answer with a 4xx status and an
errors object. The rejected RpcError exposes it as
error.errors; see RPC errors.
<form pp-component="register_form" onsubmit="submit(event)">
<input name="email" aria-invalid="{!!errors.email}" />
<p role="alert" hidden="{!errors.email}">{errors.email?.[0]}</p>
<input name="password" type="password" aria-invalid="{!!errors.password}" />
<p role="alert" hidden="{!errors.password}">{errors.password?.[0]}</p>
<button>Register</button>
<script>
const [errors, setErrors] = pp.state({});
const submit = async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
try {
await pp.rpc("register", data);
await pp.redirect("/welcome");
} catch (error) {
setErrors(error.errors ?? {}); // RpcError.errors: { field: ["message"] }
}
};
</script>
</form>