Skip to content

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.

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>
);
}

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.

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.

  • 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-invalid on 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 by useRHFIntegration(name).error. A Checkbox group borrows the same pattern, plus error="" 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.

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.

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 });

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.

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.

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.formState directly. 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 with useFormState({ 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: true scopes 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 is useWatch, never watch(name) — watch subscribes the component that owns useForm, and a memoized owner cuts the cascade before it ever reaches the control.

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.

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-*.

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.

  • The error is announced where the field is. A context error fills the same hint slot a manual error prop uses: one <p> carrying role="alert", pointed at by the control’s aria-describedby, with aria-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-describedby is merged, not replaced. Your id comes first, the hint id second.
  • Focus lands on the first failure. react-hook-form’s shouldFocusError is on by default and calls focus() 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 than type="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 one aria-describedby target 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.
  • noValidate is yours to pass. Form does 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

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.