Form
Twenty-five lines, no CSS and no state of its own: a FormProvider around a native form element, with submit wired to handleSubmit. What it buys is everything below it — every field component in this library finds the form through context and registers itself on its name alone, takes its own error into its own hint slot, and freezes while the form submits. react-hook-form is a required peer here, not an optional adapter.
- Stable
- React 19
- react-hook-form ^7.75
- No CSS
Submit with the fields empty, then fix them. Save takes a moment on purpose — watch every field freeze while it runs.
Code
import { zodResolver } from "@hookform/resolvers/zod";import { useState } from "react";import { useForm, useFormState } from "react-hook-form";import { Button, Checkbox, Form, Input } from "wayra-ui";import { z } from "zod";
// The schema is yours; the fields never see it. Anything react-hook-form// accepts as a `resolver` works the same way.const schema = z.object({ email: z.email("Enter a valid email address."), workspace: z.string().min(3, "At least three characters."), terms: z.boolean().refine((accepted) => accepted, "Accept the terms first."),});
type Values = z.infer<typeof schema>;
export function FormDemo() { const [payload, setPayload] = useState<string>(); // The consumer owns the instance: mode, resolver and defaults stay here. const form = useForm<Values>({ defaultValues: { email: "", workspace: "", terms: false }, resolver: zodResolver(schema), }); // Button is not a field, so it subscribes for itself. The three fields // below do this internally, which is what freezes them with no prop. const { isSubmitting } = useFormState({ control: form.control });
async function save(values: Values) { // Stands in for the request. The fields stay frozen until it settles. await new Promise((resolve) => setTimeout(resolve, 900)); setPayload(JSON.stringify(values)); }
function reset() { form.reset(); setPayload(undefined); }
return ( <Form form={form} onSubmit={save} className="hu-demo-col" noValidate> <Input name="email" label="Email" placeholder="ada@lovelace.dev" /> <Input name="workspace" label="Workspace" /> <Checkbox name="terms" label="Accept the terms" />
<div className="hu-demo-row"> <Button type="submit" disabled={isSubmitting}> {isSubmitting ? "Saving…" : "Save"} </Button> <Button type="button" variant="outline" color="neutral" onClick={reset}> Reset </Button> </div>
{payload && <p className="hu-readout">{payload}</p>} </Form> );}const schema = z.object({ email: z.email('Enter a valid email address.'), terms: z.boolean().refine((accepted) => accepted, 'Accept the terms first.'),});
const form = useForm({ defaultValues: { email: '', terms: false }, resolver: zodResolver(schema),});
<Form form={form} onSubmit={save}> <Input name="email" label="Email" /> <Checkbox name="terms" label="Accept the terms" /> <Button type="submit">Save</Button></Form>Three props matter: form is the useForm return value, onSubmit receives the typed
values once validation passes, and onError — optional — receives the field errors when it
does not. Everything else lands on the <form> element untouched: id, noValidate,
aria-label, className, ref.
There is no Field, no FormItem and no render prop. The fields are the components you
already use, and the only thing that changes about them inside a Form is that name starts
meaning something.
The consumer owns the instance
Section titled “The consumer owns the instance”Form calls no useForm of its own, and it has no mode, resolver, defaultValues or
criteriaMode mirror props. You create the instance, you pass it in.
That is not minimalism for its own sake. A mirror prop has to be either a one-way copy —
which drifts the moment you need form.reset(), form.setValue() or formState outside the
JSX — or a second source of truth for options react-hook-form already resolves once at
useForm time. Holding the instance yourself keeps every one of its APIs reachable, and
keeps the schema library entirely out of this library: Form never sees your resolver.
function Signup() { const form = useForm({ defaultValues: { email: '' } });
// Everything react-hook-form exposes stays in reach. useEffect(() => form.setFocus('email'), [form]);
return ( <Form form={form} onSubmit={save}> … </Form> );}What a field gets for free
Section titled “What a field gets for free”Inside a Form, a field component with a name picks up three behaviours and no props:
| Behaviour | What happens | Who wins |
|---|---|---|
| Registration | The field calls register(name) itself and merges the halves — handlers composed, ref merged. |
— |
| Error | That field’s message takes the hint slot, with role="alert" and aria-invalid. |
an explicit error prop |
| Freeze | The field disables itself while onSubmit is in flight. |
an explicit disabled prop, including disabled={false} |
“Explicit wins” is literal: the resolved values are error ?? contextError and
disabled ?? isSubmitting. A prop you passed is never overwritten by the form.
noRhf opts one instance out of all three. It stays inside the <form>, keeps its layout and
its styling, and simply never registers — for a control that shares the form’s shape but not
its data, or one you are wiring by hand.
A field with no name takes the same branch. There is nothing to register under, so it
behaves exactly as it would outside a form.
What lands in the payload
Section titled “What lands in the payload”These are react-hook-form’s rules for the underlying element, not this library’s — the field components add nothing to them:
| Control | Value in onSubmit |
|---|---|
Input, Textarea, Select, InputPassword, PinCode |
the string |
Select multiple |
the array of chosen values |
A checkbox with no value — Toggle, Swap, a lone Checkbox |
true / false |
A checkbox carrying value="x" |
"x" when checked, false when not |
Several Checkboxes sharing one name |
the array of the checked ones |
Radio options sharing one name |
the chosen option’s value |
Rating |
the score as a string — "3", "2.5", "" |
The group shape is the one with a catch: it needs defaultValues: { toppings: [] }. That
empty array is what tells react-hook-form the field is a group — without it a single checked
box posts its bare value instead of a one-item array.
indeterminate never reaches the payload. It is a DOM property rather than an attribute, so
only checked is ever read, and a mixed box reports as unchecked until someone clicks it.
Two fields deviate on purpose
Section titled “Two fields deviate on purpose”- Radio marks, it does not speak. A radio error belongs to
the group, not to whichever option happens to be first, so the context error sets
aria-invalidon every option — which turns all the rings red — and never fills a per-item hint slot. The message is yours to place, once, after the<fieldset>, fed byuseRHFIntegration(name).error. A Checkbox group borrows the same pattern, pluserror=""per option to silence the repeats. - Swap registers and freezes but takes no error. It has no label and no hint slot to put one in, so a message about that field belongs beside the form.
Outside a Form there is no library API
Section titled “Outside a Form there is no library API”This is worth saying plainly, because it looks like a missing feature: there is no register
prop, and there never was one to remove without cost.
const { register, handleSubmit } = useForm();
<form onSubmit={handleSubmit(save)}> <Input {...register('email')} label="Email" /></form>That works, and nothing in this library made it work. register() returns name, onChange,
onBlur and ref; spread as plain JSX props they land on the underlying element, and the
field composes your onChange/onBlur alongside them and merges the ref the way it merges
any ref. The same spread works on a native <input>.
So the two wirings are: inside a Form, pass name; outside one, spread register. There
is no third mode and no adapter layer.
useRHFIntegration
Section titled “useRHFIntegration”The hook every field component calls internally, exported for everything the library does not ship: a native element, somebody else’s component, or a piece of markup that needs to read a field’s error without being that field.
const field = useRHFIntegration<Values>(name, { error, disabled, noRhf });What it returns
Section titled “What it returns”It returns a discriminated union on hasFormContext, so one property tells you which world
you are in.
| Property | Inside a Form (WithContext<T>) |
Outside, or noRhf, or no name (WithoutContext) |
|---|---|---|
hasFormContext |
true |
false |
error |
the explicit error, else this field’s error in the form |
the explicit error, echoed back |
disabled |
the explicit disabled, else isSubmitting |
the explicit disabled, echoed back |
isSubmitting |
the form’s flag | always false |
registerRef |
the ref half of register(name) |
undefined |
registerRest |
everything else from register(name) |
undefined |
methods |
the UseFormReturn<T> instance |
undefined |
The two register keys are declared as undefined on the disconnected side rather than being
absent, so you can read them off the union without narrowing first — {...field.registerRest}
is a harmless no-op when there is no form above.
The hand-wiring recipe
Section titled “The hand-wiring recipe”register(name) arrives split in two: registerRest spreads onto the control, registerRef
goes on its ref. The split exists because a field component has to merge the registered
ref with whatever ref the consumer passed — mergeRefs(ref, field.registerRef) — and a ref
buried inside a spread cannot be merged. Hand-wired controls follow the same shape.
Code
import { useState } from "react";import { useForm } from "react-hook-form";import { Button, Form, Input, useRHFIntegration } from "wayra-ui";
type Values = { email: string; country: string };
// The hand-wired half, on a control this library does not ship. It has to be// a *child* of Form: the provider sits below the component that renders Form,// so a hook called up there sees no context and registers nothing, silently.function CountryField() { const country = useRHFIntegration<Values>("country");
return ( <label style={{ display: "grid", gap: "0.25rem", inlineSize: "100%" }}> Country <select {...country.registerRest} ref={country.registerRef} disabled={country.disabled} > <option value="">Pick one</option> <option value="ar">Argentina</option> <option value="ec">Ecuador</option> </select> </label> );}
export function MixedWiring() { const [payload, setPayload] = useState<string>(); const form = useForm<Values>({ defaultValues: { email: "", country: "" }, });
return ( <Form form={form} onSubmit={(values) => setPayload(JSON.stringify(values))} className="hu-demo-col" noValidate > <Input name="email" label="Email" /> <CountryField /> <Button type="submit">Submit</Button> {payload && <p className="hu-readout">{payload}</p>} </Form> );}Both fields above register into the same form and arrive in the same payload — one because it
is a wayra-ui field with a name, the other because it was wired by hand.
Two rules the hook exists to keep
Section titled “Two rules the hook exists to keep”Both are about what re-renders, and both are load-bearing under the React Compiler — which is on for this library’s build:
- Never read
methods.formStatedirectly. That read does not create a subscription that survives memoization, so the value goes stale and the message that should appear never does. The hook subscribes withuseFormState({ control, name, exact: true })instead. It is the read that makes other codebases sprinkle"use no memo"over their form code; this one cannot, because the build fails on any compiler opt-out that is not on a one-file allowlist. - Never subscribe a field to the whole form.
exact: truescopes the subscription to one field, so a validation error on the email input does not re-render the country select beside it. The equivalent rule for reading a value isuseWatch, neverwatch(name)—watchsubscribes the component that ownsuseForm, and a memoized owner cuts the cascade before it ever reaches the control.
Why name is a plain string
Section titled “Why name is a plain string”InputProps is not generic and name is typed string, not FieldPath<TFieldValues>. That
looks like a gap in the types, and it is a decision.
Inference needs a position to infer from. The removed register prop was that position; a
FieldPath<T> parameter cannot infer T on its own, and React context carries no type
parameter, so inside a Form the generic always collapsed to FieldValues anyway. A generic
that is always FieldValues is worse than no generic: it costs every consumer a type
parameter and returns nothing.
Typed autocomplete for name would need a different design — a createForm<Values>() factory
returning a bound Input. Until that exists, useRHFIntegration<Values>('country') is where
you can still hand the types over explicitly.
API reference
Section titled “API reference”FormProps<TFieldValues> extends ComponentProps<"form"> minus onSubmit and onError —
both names are re-purposed for the react-hook-form submit pipeline, so the DOM onError
(resource load failures) goes out with them.
| Prop | Type | Default | Description |
|---|---|---|---|
form |
UseFormReturn<TFieldValues> |
— required | The useForm return value. You own the instance. |
onSubmit |
SubmitHandler<TFieldValues> |
— required | Called with the typed values once validation passes. |
onError |
SubmitErrorHandler<TFieldValues> |
— | Called with the field errors when it does not. |
children |
ReactNode |
— | The fields. They see the provider; anything above Form does not. |
Everything else is the native attribute, typed: id, noValidate, className, style,
ref, action, aria-*.
useRHFIntegration
Section titled “useRHFIntegration”function useRHFIntegration<T extends FieldValues = FieldValues>( name: FieldPath<T> | undefined, options?: { error?: FieldError; disabled?: boolean; noRhf?: boolean },): RHFIntegration<T>;| Argument | Type | Description |
|---|---|---|
name |
FieldPath<T> | undefined |
The key to register under. undefined takes the disconnected branch. |
options.error |
FieldError |
Explicit error, which wins over the one the form carries. |
options.disabled |
boolean |
Explicit disabled, which wins over the submitting freeze. |
options.noRhf |
boolean |
Ignore any surrounding form: take the disconnected branch. |
RHFIntegration<T> is WithContext<T> | WithoutContext; both shapes are exported, and the
table above is their field list.
Accessibility
Section titled “Accessibility”- The error is announced where the field is. A context error fills the same hint slot a
manual
errorprop uses: one<p>carryingrole="alert", pointed at by the control’saria-describedby, witharia-invalid="true"on the control itself. One slot, never two paragraphs — an error replaces the helper text it invalidates, because a hint that is now wrong is not worth keeping on screen. - A consumer
aria-describedbyis merged, not replaced. Your id comes first, the hint id second. - Focus lands on the first failure. react-hook-form’s
shouldFocusErroris on by default and callsfocus()on the first registered element with an error. The library keeps that reachable: a control whose value does not live in one native element registers a focusable visually hidden anchor rather thantype="hidden", because a hidden input cannot take focus and the whole feature would die silently. That anchor forwards its focus to the control the user can actually see. - A radio group’s message needs a home. Because Radio only marks,
point the
<fieldset>at onearia-describedbytarget that is rendered from the start and empty until there is something to say — a live region that appears at the same moment as its text is often missed. noValidateis yours to pass.Formdoes not set it. Leave it off and any native constraint on a field —required,type="email",pattern— is checked before submit ever reaches your resolver, and the browser’s own bubble appears instead of the message you wrote.
Do and don’t
Section titled “Do and don’t”Do
Add the part after the @, like ana@example.com.
The form's message in the field's own hint slot — an alert role, a wired aria-describedby, and aria-invalid on the control. That is what a name inside Form gets you, with no prop.
Don't
1 error found. Please review the form.
A summary at the top and nothing on the field. The user is told a form is wrong and left to find out which part.
Do
const { errors } = useFormState({
control,
name,
exact: true,
});A subscription scoped to one field. It survives memoization, and another field’s error does not re-render this one.
Don't
const error =
methods.formState.errors[name];A direct formState read. Under the React Compiler it creates no subscription, so the value goes stale and the message never appears.