|Kit

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 controlsInput, Textarea, PasswordInput — are event-based: onChange(event) is forwarded straight to the DOM element, so register() works as-is.
  • Every other control is value-based: onChange(value) receives the next value directly (onCheckedChange(boolean) for Checkbox / Switch). That is exactly the shape of react-hook-form's Controller field.onChange and TanStack's field.handleChange.

Three props are load-bearing and honored by every control:

PropWhy it matters
onBlurDrives touched/blurred validation (mode: "onBlur", field.handleBlur).
refLands on the focusable element, so react-hook-form can focus the first invalid field.
nameProduces 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

Controlreact-hook-formTanStack 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 error prop — every control carries label / description / error / required built in, except the base Input, which takes a wrapping Field error. Either way the control flips to the invalid (red) state and the message is associated for assistive tech.
  • invalid vs error. Use invalid to drive only the red state (e.g. from fieldState.invalid) when the message renders elsewhere; error does both.
  • Validation lives in your form library, not the kit. The controls surface state — they never validate.

On this page