Forms
Bind any kit control to react-hook-form, TanStack Form, or a plain <form> — the controls share one library-agnostic contract, so there is no adapter to install.
The kit ships no form-library adapter. Every control exposes the same controlled
surface — so react-hook-form, TanStack Form, Formik, or a native <form> bind to it
directly. The contract is the integration.
Two onChange shapes exist by design, matching how the libraries themselves split controls:
- Native controls —
Input,Textarea,PasswordInput— are event-based:onChange(event)is forwarded straight to the DOM element, soregister()works as-is. - Every other control is value-based:
onChange(value)receives the next value directly (onCheckedChange(boolean)forCheckbox/Switch). That is exactly the shape of react-hook-form'sControllerfield.onChangeand TanStack'sfield.handleChange.
Three props are load-bearing and honored by every control:
| Prop | Why it matters |
|---|---|
onBlur | Drives touched/blurred validation (mode: "onBlur", field.handleBlur). |
ref | Lands on the focusable element, so react-hook-form can focus the first invalid field. |
name | Produces a submittable value (a real or hidden input), so a native <form> includes it. |
invalid is independent of the Field error slot, so a form library can
flip the red state from fieldState.invalid even when the message lives elsewhere.
Which binding for which control
| Control | react-hook-form | TanStack Form |
|---|---|---|
Input, Textarea, PasswordInput | {...register("x")} | onChange={(e) => field.handleChange(e.target.value)} |
Select, Autocomplete, NumberInput, Slider, RadioGroup, SegmentGroup | <Controller> → onChange={field.onChange} | onChange={field.handleChange} |
Checkbox, Switch | <Controller> → onCheckedChange={field.onChange} | onCheckedChange={field.handleChange} |
MultiSelect, TagsInput, TreeMultiSelect, PinInput, FileUpload | <Controller> → onChange={field.onChange} | onChange={field.handleChange} |
react-hook-form
Native controls bind with register(); everything else goes through Controller (its
field.onChange takes the raw value, matching the kit's value-based onChange).
'use client';
import { useForm, Controller } from 'react-hook-form';
import { Input } from '@42/ui-react/input';
import { Field } from '@42/ui-react/field';
import { Select } from '@42/ui-react/select';
import { Checkbox } from '@42/ui-react/checkbox';
import { Button } from '@42/ui-react/button';
type Values = { email: string; project: string | null; terms: boolean };
export function SignupForm() {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<Values>({ mode: 'onBlur', defaultValues: { project: null, terms: false } });
return (
<form onSubmit={handleSubmit((values) => console.log(values))} noValidate>
{/* Native control — register supplies value/onChange/onBlur/name/ref */}
<Field label="Email" error={errors.email?.message}>
<Input {...register('email', { required: 'Email is required' })} type="email" />
</Field>
{/* Value-based control — Controller maps 1:1 onto onChange(value) */}
<Controller
name="project"
control={control}
rules={{ required: 'Pick a project' }}
render={({ field, fieldState }) => (
<Select
label="Project"
data={['Libft', 'ft_printf', 'Minishell']}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
invalid={fieldState.invalid}
error={fieldState.error?.message}
/>
)}
/>
{/* Boolean control — onCheckedChange is the value-based handler */}
<Controller
name="terms"
control={control}
rules={{ required: 'You must accept the terms' }}
render={({ field, fieldState }) => (
<Checkbox
label="I accept the terms"
checked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
name={field.name}
error={fieldState.error?.message}
/>
)}
/>
<Button type="submit">Create account</Button>
</form>
);
}Focus-on-error works out of the box: react-hook-form's shouldFocusError (on by
default) focuses the first invalid field on submit via the ref you forwarded — the
Select trigger, the Input element, and so on.
TanStack Form
form.Field exposes field.state.value, field.handleChange(value), and
field.handleBlur(). Value-based controls wire onChange={field.handleChange} with no
wrapper; native controls read e.target.value.
'use client';
import { useForm } from '@tanstack/react-form';
import { Textarea } from '@42/ui-react/textarea';
import { Switch } from '@42/ui-react/switch';
import { Button } from '@42/ui-react/button';
export function FeedbackForm() {
const form = useForm({
defaultValues: { bio: '', notify: false },
onSubmit: ({ value }) => console.log(value),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Native control — read the event target */}
<form.Field
name="bio"
validators={{ onBlur: ({ value }) => (value ? undefined : 'Required') }}
>
{(field) => (
<Textarea
label="Bio"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
error={field.state.meta.errors[0]}
/>
)}
</form.Field>
{/* Value-based control — handleChange takes the value directly */}
<form.Field name="notify">
{(field) => (
<Switch
label="Email me about evaluations"
checked={field.state.value}
onCheckedChange={field.handleChange}
onBlur={field.handleBlur}
/>
)}
</form.Field>
<Button type="submit">Save</Button>
</form>
);
}Native HTML forms
No JavaScript state required — give each control a name and read it from FormData.
Native controls submit their own element; value-based controls render hidden inputs
(Select a hidden <select>, MultiSelect / TagsInput / TreeMultiSelect one hidden
<input> per value, Checkbox / Switch / RadioGroup / SegmentGroup / PinInput /
FileUpload their machine's hidden input).
async function save(formData: FormData) {
'use server';
const email = formData.get('email');
const project = formData.get('project');
const tags = formData.getAll('tags'); // MultiSelect / TagsInput submit repeated names
}
<form action={save}>
<Input name="email" type="email" />
<Select name="project" data={['Libft', 'ft_printf']} />
<TagsInput name="tags" />
<Button type="submit">Save</Button>
</form>;Notes
- Error display. Pass the message to the control's
errorprop — every control carrieslabel/description/error/requiredbuilt in, except the baseInput, which takes a wrappingFielderror. Either way the control flips to the invalid (red) state and the message is associated for assistive tech. invalidvserror. Useinvalidto drive only the red state (e.g. fromfieldState.invalid) when the message renders elsewhere;errordoes both.- Validation lives in your form library, not the kit. The controls surface state — they never validate.