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 —
TextInput,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 |
|---|---|---|
TextInput, 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 { TextInput } from '@42-central/ui-react/text-input';
import { Select } from '@42-central/ui-react/select';
import { Checkbox } from '@42-central/ui-react/checkbox';
import { Button } from '@42-central/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 */}
<TextInput
label="Email"
type="email"
error={errors.email?.message}
{...register('email', { required: 'Email is required' })}
/>
{/* 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-central/ui-react/textarea';
import { Switch } from '@42-central/ui-react/switch';
import { Button } from '@42-central/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}>
<TextInput label="Email" 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. The one exception is the bareInputshell, which is meant to be composed: wrap it in aField, or useTextInput, its field-aware sibling. Either way the control flips to the invalid (red) state and the message renders below the control and is associated for assistive tech — independently ofdescription, which renders above the control, so both can be shown at once. 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.
Changelog
Every form control speaks one contract (
value/onChange/onBlur/ref/name): react-hook-form, TanStack Form and native forms work without an adapter
Tabs
A navigation control on Ark UI's Tabs machine — a row (or column) of triggers, each revealing its own content panel, with a pill that slides to the active trigger. Visually SegmentGroup's twin.
Testing
Query by role and accessible name first; every component also exposes a testId prop for repeated or data-driven cases where that's ambiguous — plus a Playwright quickstart.