Forms
A form in Dziry is markup, CSS and a schema. There is no form library and no per-field state to manage: the compiler sees the whole form, so it determines the payload's shape before the app runs.
A minimal form
export function save(data: { email: string }) {
console.log(data.email);
}
export const SignUp = () => (
<form onSubmit={save}>
<input name="email" />
<button>Sign up</button>
</form>
);
No signal and no state module. onSubmit receives an object keyed by each
control's name, and pressing Enter in the field submits the same way the
button does.
Where the value lives: the compiler declares a cell for every named field
that has no binding, seeded from its value attribute (the equivalent of a
browser's default value). That cell cannot be named from outside the generated
module, so form fields never become a second state API — the payload is how
you read them.
To hold a field's value yourself, give it bind:value and it uses your signal
instead. A field cannot have both a name-declared cell and a binding,
because two cells for one field could disagree.
Payload types
The compiler knows a checkbox from a number field, so the payload is typed the way you would have typed it by hand:
| Markup | Payload type |
|---|---|
<input name="x">, <textarea name="x"> | string |
<input name="x" type="number"> | number | undefined — never NaN |
<input name="x" type="checkbox"> | boolean |
<input name="x" type="radio"> × n | string | undefined — the checked one's value |
<select name="x"> | string |
<select name="x" multiple>, or two controls sharing a name | string[] |
Which controls appear in the payload follows the browser's rules: a control
with no name is left out; so is a disabled one, including one disabled through
an enclosing <fieldset disabled>; an unticked checkbox contributes nothing;
and an <option> with no value submits its trimmed text.
The key set never changes shape between submits. Two controls sharing a name always produce an array — not an array when both are filled and a string when one is — because the shape is decided at build time, so your schema can rely on it.
Groups and nesting
field on an element that wraps controls names a group:
<form onSubmit={save}>
<div field="name">
<input />
</div>
<div field="position">
<input name="x" />
<input name="y" />
</div>
<div field="address">
<div field="city"><input /></div>
</div>
</form>;
produces { name: string, position: { x, y }, address: { city } }.
The rule: the wrapper chain is the path. A wrapper holding one bare
control is that field. Named controls inside a wrapper become its
properties. Wrappers nest. An element without field is transparent, so a
layout <div> changes nothing.
A wrapper holding a bare control and a named one would need to be a string and an object at once — that is a build error.
name="user[email]" is the literal key "user[email]" everywhere in the web
platform — in FormData, in the urlencoded body, in URLSearchParams. The
bracket convention belongs to server-side parsers (PHP, Rack, qs), each with
its own dialect. Dziry nests by structure instead: nothing is parsed at run
time, and a conflicting path is reported at build time.
Radio groups
A radio set shares a name — that is what makes it a set. Inside a wrapper,
the radio's name groups it and the wrapper names it:
<div field="plan">
<input type="radio" name="plan" value="free" />
<input type="radio" name="plan" value="pro" />
</div>;
produces { plan: "free" | "pro" | undefined } — one key, not plan.plan.
Put one radio group per wrapper. Two groups under one wrapper would both claim the wrapper's key, which is a build error.
Repeating rows
A field wrapper holding a map() is an array field, and its value is
the array the rows came from:
type Job = { id: number; title: string; start: string; end: string };
export const jobs = signal<Job[]>([{ id: 1, title: "", start: "", end: "" }]);
let nextId = 2;
export const addJob = () =>
jobs.set((rows) => [...rows, { id: nextId++, title: "", start: "", end: "" }]);
export const removeJob = (job: Job) =>
jobs.set((rows) => rows.filter((row) => row.id !== job.id));
<div field="experience">
{jobs.map(
(job) => (
<div>
<input bind:value={job.title} />
<input bind:value={job.start} />
<input bind:value={job.end} />
<button type="button" onClick={removeJob}>remove</button>
</div>
),
{ key: (job) => job.id },
)}
</div>
<button type="button" onClick={addJob}>add a row</button>;
produces { experience: Job[] }, one entry per live row.
This is the one field whose state you own, for a structural reason: every
other field gets a compiler-declared cell, but a row's controls live in a list
arena — interchangeable replicas of one template — so there is nothing stable
to attach a per-row cell to. The array already has one entry per row and a key
for each, so the array is the state: bind:value={job.title} writes back
into it, and adding a row is an ordinary signal.set.
It follows that reordering rows reorders the payload, a removed row is gone rather than blank, and the row type in the payload is the type you declared.
bind:value on a row propertyInside a map(), bind:value takes the row's own property rather than a
signal. The callback runs once against a recording proxy, so job.title is a
path at build time — the same mechanism {job.title} uses to render. Typing
replaces the item and the array through an ordinary signal.set; nothing is
mutated in place.
Two details:
- The payload entry is the item as authored,
idincluded. The compiler does not guess which properties are "really" fields; drop the key in your schema if you don't want it. - A row's errors appear on submit, not while typing — the pristine-field check compares against a compiled constant, and an array has none. After the first failed submit they update live like every other field.
Per-row error messages
Put a <span error /> inside the template and each row shows its own message:
<div field="experience" errorClassName="group/error">
{jobs.map(
(job: Job) => (
<div>
<input bind:value={job.title} />
<span error />
</div>
),
{ key: (job: Job) => job.id },
)}
<span error />
</div>;
An issue at experience.0.title lands in row 0 and nowhere else, matched by
data position rather than by slot — a reorder cannot carry a message to the
wrong row.
The section's own <span error /> shows only section-level issues — "add at
least one job" for an issue at experience itself — and stays silent when a
row already shows the message. The section's errorClassName still applies,
because a broken row is also the section's problem.
Styling an invalid control
The wrapper's class styles everything around a field; the control itself uses a pseudo-class:
input:invalid { border-color: #f43f5e }
:invalid is live on any control the current validate={…} rejected, and
clears when the next validation passes. It is also the only way to style one
list row and not another: replicas share a style row, so a conditional class
on a row's input applies to every row, while :invalid is resolved per node
in the controls table.
There is no :user-invalid — it differs from :invalid only in when a
browser lets it match, and that timing is already covered by validateOn and
the pristine-field behavior.
input:invalid and input[type="text"] are both specificity (0,1,1), so
source order decides. An :invalid rule written above the field's resting
color is overwritten by it — which looks exactly like the pseudo-class never
matching. Write the :invalid rule after the base rule.
Validation
import * as z from "zod";
export const Login = z.object({ email: z.email(), age: z.number().min(18) });
<form validate={Login} onSubmit={save} onInvalid={showErrors}>;
validate accepts any Standard Schema (Zod 4, Valibot and ArkType
implement it natively), any Effect schema, or a plain function returning
issues. Dziry depends on none of these libraries — Standard Schemas are used
through their ~standard property, and an Effect schema is converted with
Effect's own helper behind a lazy import.
A schema also narrows what onSubmit receives: the payload goes in, the
schema's output comes out, so a z.coerce.date() field arrives as a Date.
When validation fails, onSubmit does not run and onInvalid receives the
issues, normalized to { path, message }[] regardless of which library
produced them.
When validation runs
<form validateOn="change" validate={Login} onSubmit={save}>;
validateOn is "submit" (the default), "change", or "blur". Two
behaviors apply in every mode:
- After a failed submit, the form re-validates as fields change, so an error clears the moment it is fixed.
- Before any submit, a field shows an error only once its value has moved off its initial one — a pristine form does not open covered in red.
There is no touched or dirty state to manage: the first is what
validateOn covers, and the second is derived by comparing against the
initial value the compiler recorded.
Showing errors with CSS
Error state is one class on the wrapper:
<div field="email" errorClassName="group/error">
<input className="error:border-red-500" />
<span error className="hidden error:block" />
</div>;
@custom-variant error (.group\/error &);
The wrapper carries group/error while its field has an issue — "its" meaning
any issue whose path starts with the wrapper's own, so a position wrapper
lights up for a problem at position.x. Everything inside is an ordinary
descendant selector: the input's border and the message's visibility both
follow from the class on the wrapper, with no JavaScript involved. It compiles
to a handful of style-table writes.
<span error /> is where the message text goes. Its content becomes a binding
to a compiler-declared cell; any text you write inside it is placeholder prose
that never ships.
One message per field in a group
A marker can name a field, so a group's messages divide up instead of collapsing into one line:
<div field="address" errorClassName="group/error">
<div>
<input name="street" />
<span error="street" />
</div>
<div>
<input name="city" />
<span error="city" />
</div>
<span error />
</div>;
Marker names are relative to their wrapper, exactly as control names are —
error="street" inside field="address" means address.street, and dots go
deeper. Relative, so renaming or nesting the wrapper never means editing every
marker inside it.
Each marker shows the first issue under its own path that no more specific
marker would show. With both fields empty, each leaf shows its own message and
the bare marker stays silent; with an issue at address itself ("street and
city cannot be the same"), the leaves are clean and the bare marker shows it.
No message appears twice.
The class stays one per wrapper — errorClassName means "something under here
is wrong", however many messages describe it. A marker naming a field that
does not exist is a build warning, since it could never fill.
@custom-variant error (.group\/error &) emits .group\/error .error\:block,
a plain descendant selector. Tailwind's default form emits
:is(:where(.group\/error) *), and the * inside :is() is not a selector
Dziry parses.
Multiple fields can share the class name and stay independent — error patches are keyed on each field's own state, not on the string.
Reacting to one field
A submit button that stays disabled until a box is ticked needs the app to know about that box. This is the one place the payload-only rule costs an extra signal:
export const termsAccepted = signal(false);
export const onTermsChange = (on: boolean) => termsAccepted.set(on === true);
export const cannotSubmit = computed(() => !termsAccepted);
<div field="terms">
<input type="checkbox" onChange={onTermsChange} />
</div>
<button type="submit" disabled={cannotSubmit}>sign up</button>;
disabled takes a signal, and because it is a control flag rather than a
class it does three things at once: the button greys out through :disabled,
the engine refuses presses on it, and Enter is refused too — a form whose
submit button is disabled has no way in.
The checkbox keeps its compiler-declared cell, so terms remains in the
payload. The onChange is a second reader of the same click; the cell is
written before any handler runs, so the two cannot disagree.
bind:checked is plannedA named field's cell is deliberately unreachable from outside the generated
module, so a field that drives something else on the page currently needs the
app to hold a copy of its value. bind:checked — making your signal be the
cell, as bind:value does for text — is the planned fix and is not built yet.
Showing a native dialog
import { alert } from "dziry";
export const onSignUp = (data: unknown) => {
alert(JSON.stringify(data, null, 2), { title: "onSubmit received" });
};
alert() opens the platform's own modal dialog — see
alert. Import it explicitly: Bun defines
a global alert() that reads stdin, which would hang the app thread.
File inputs
<input type="file"> opens the platform's file dialog. The chosen path lands
in the input's bound signal and onChange fires. The value is the path,
not a File object — reading from disk is left to you, so the engine never
blocks a frame on I/O:
export const picked = signal("");
<input type="file" accept="image/*,.png" multiple bind:value={picked} />;
accept narrows the dialog's filter; multiple allows several picks, whose
paths arrive newline-joined. Three helpers read the path:
import { fileInfo, readFile, readFileText } from "dziry";
const info = await fileInfo(picked); // { path, name, size, type }
const bytes = await readFile(picked); // Uint8Array — what <img src> needs
const text = await readFileText(picked); // string — for .txt, .json, .csv
fileInfo reads the size from disk and derives the MIME type from the
extension; readFile and readFileText load the whole file.
Current limitations
- A named control inside a
map()row is not collected. Rows reach the payload as an array field throughbind:valueon row properties; anameinside a template would be the same string in every row. The build reports it. errorClassNameon a wrapper inside a row does nothing — a class is a style row and replicas share one. Style the row's controls with:invalid, which is per node.- A file input contributes nothing to the payload. The chosen path lands
in its bound signal; there is no
Fileobject. Read it withfileInfo/readFile/readFileText. - A named submit button adds no entry of its own. A browser includes
name=valuefor the button that submitted; Dziry does not. In a two-button form, use twoonClickhandlers.
form="id" is fully supported: a control it moves belongs to that form
for every purpose — payload, default button, implicit submission — even when
written outside the form or inside a different one. An id that names no form
leaves the control owned by nothing, matching browser behavior for a typo.