# Forms (/docs/forms)



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`](/docs/text-input), [`Input`](/docs/input),
  [`Textarea`](/docs/textarea), [`PasswordInput`](/docs/password-input) — 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`](/docs/checkbox) /
  [`Switch`](/docs/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:

| 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`](/docs/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 [#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 [#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`).

```tsx
'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 [#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`.

```tsx
'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 [#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).

```tsx
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 [#notes]

* **Error display.** Pass the message to the control's `error` prop — every control carries
  `label` / `description` / `error` / `required` built in. The one exception is the bare
  [`Input`](/docs/input) shell, which is meant to be composed: wrap it in a
  [`Field`](/docs/field), or use [`TextInput`](/docs/text-input), 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 of `description`, which renders
  above the control, so both can be shown at once.
* **`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.
