# React Hook Form

> Wire the chroma-panel React color picker into React Hook Form, uncontrolled with a name or wrapped in Controller, plus which change event to validate on.

Source: https://chroma-panel.jscrate.dev/react/frameworks/react-hook-form
Last updated: 2026-09-18

chroma-panel ships no React Hook Form integration, and it does not need one.
You can use the React color picker with React Hook Form in two ways: leave the
field uncontrolled and let `ColorInput` submit itself, or wrap it in
`<Controller>` so the form owns the value. This page shows both, and which of
the two change events to validate on.

## Install

```bash
npm install chroma-panel
```

React Hook Form is not part of chroma-panel. The examples below assume you
already have it in your app and use the v7 `Controller` API.

## Uncontrolled: let ColorInput submit itself

Give `ColorInput` a `name` and it renders a hidden input that submits with the
surrounding form, in whichever `format` you set. React Hook Form never sees the
field, so you read it from the form data yourself:

```tsx
import { useRef } from "react";
import { useForm } from "react-hook-form";
import { ColorInput } from "chroma-panel";

type Values = { label: string };

export function BrandForm() {
  const formRef = useRef<HTMLFormElement>(null);
  const { register, handleSubmit } = useForm<Values>({
    defaultValues: { label: "" },
  });

  const onSubmit = (values: Values): void => {
    const form = formRef.current;

    if (form === null) {
      return;
    }

    const brand = String(new FormData(form).get("brand") ?? "");

    save({ label: values.label, brand });
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit(onSubmit)}>
      <input {...register("label")} />
      <ColorInput
        name="brand"
        defaultValue="#3366cc"
        aria-label="Brand color"
      />
      <button type="submit">Save</button>
    </form>
  );
}
```

This is the smaller of the two. The cost is that the color stays outside the
form state: it does not show up in `formState`, `watch` or a resolver, and
`reset()` does not touch it. `form.reset()` still returns the control to its
`defaultValue`, because that is native behavior. Pick this when the color needs
no validation beyond `required`, which `ColorInput` handles natively — see
[forms](https://chroma-panel.jscrate.dev/react/handbook/forms).

## Controller: let the form own the value

Wrap `ColorInput` in `<Controller>` and it becomes an ordinary controlled field.
Pass `value` and `onChange` yourself rather than spreading `field`:

```tsx
import { Controller, useForm } from "react-hook-form";
import { ColorInput, type ColorChangeResult } from "chroma-panel";

type Values = { brand: string };

export function BrandForm() {
  const { control, handleSubmit } = useForm<Values>({
    defaultValues: { brand: "#3366cc" },
  });

  return (
    <form onSubmit={handleSubmit((values) => save(values))}>
      <Controller
        name="brand"
        control={control}
        rules={{ required: "Pick a color" }}
        render={({ field, fieldState }) => (
          <div>
            <ColorInput
              value={field.value}
              onChange={(result: ColorChangeResult) =>
                field.onChange(result.hex)
              }
              onChangeComplete={() => field.onBlur()}
              aria-label="Brand color"
            />
            {fieldState.error && <p role="alert">{fieldState.error.message}</p>}
          </div>
        )}
      />
      <button type="submit">Save</button>
    </form>
  );
}
```

Two things to keep straight:

- **Do not spread `{...field}` onto `ColorInput`.** `field.onChange` would be
  handed a color object instead of the string you want stored, and `field.ref`
  would not attach, because `ColorInput` takes no `ref`.
- **Leave `name` off `ColorInput` here.** With a `name` it renders its own
  hidden input, which would submit the color a second time alongside the value
  the form already holds.

`field.value` is a string, which is what `ColorInput` expects. It also accepts an
`Hsva` object if you would rather store the color without the rounding that hex
applies — the trade-off is in
[controlled and uncontrolled](https://chroma-panel.jscrate.dev/react/handbook/controlled).

## Which event to validate on

`onChange` fires continuously while you drag the panel. `onChangeComplete` fires
once, when you let go. That distinction decides how your validation mode
behaves:

| `useForm` mode         | What happens                               |
| ---------------------- | ------------------------------------------ |
| `"onSubmit"` (default) | Validates once on submit. Nothing to tune. |
| `"onBlur"`             | Validates when you call `field.onBlur()`.  |
| `"onChange"`           | Revalidates on every frame of a drag.      |

The example above calls `field.onBlur()` from `onChangeComplete`, so with
`mode: "onBlur"` the field is checked once per drag rather than sixty times a
second. If you are on `mode: "onChange"` and the form feels heavy, that is the
first thing to change.

`onChange` and `onChangeComplete` are both listed with the rest of the props on
[ColorInput](https://chroma-panel.jscrate.dev/react/components/color-input).
