react-data-form
Writing a controller
Two props, no registration step: the extension point of the library.
The catalogue covers a lot, but not your domain. A controller is an ordinary component with two props, and nothing has to be registered for the library to use it.
The whole contract#
interface InputControllerInterface<F extends FormInputInterface = FormInputInterface> {
formInput: F
onChange: (formInput: F) => void
}That is the entire extension point. InputControllerProps<T> is the same thing with the value typed, which is what you will normally write.
A first controller#
import type { InputControllerProps } from "react-data-form"
export function ColorInputController({
formInput,
onChange,
}: InputControllerProps<string>) {
return (
<input
type="color"
value={formInput.value ?? "#7c5cff"}
disabled={formInput.readonly}
onChange={(event) =>
// Hand the whole field back, not just the value.
onChange({ ...formInput, value: event.target.value })
}
/>
)
}
// Then, anywhere:
inputs: {
brand: { label: "Brand colour", controller: ColorInputController },
}Two controllers written in this page
Spread the field, don’t rebuild it
Always call onChange({ ...formInput, value }). The field carries more than its value — its violations, its options, its id — and replacing it with a fresh object drops all of that.
Reading the rest of the field#
A controller decides for itself which keys it honours. Reading these four is what makes a custom controller feel like the built-in ones:
| Name | Type | Default | Description |
|---|---|---|---|
formInput.value | T | undefined | — | The current value. Always handle undefined. |
formInput.readonly | boolean | — | Disable the control. useForm drops the change anyway, but an enabled control that ignores clicks is worse than a disabled one. |
formInput.placeholder / description | string | — | Passed through to whatever you render. |
formInput.valueOptions / getValueOptions / onSearch | options | — | For a choice control, so a field can move between your controller and a built-in one unchanged. |
A controller fetching its own options should read valueOptions first and fall back to getValueOptions:
export function MyPicker({ formInput, onChange }: InputControllerProps<string>) {
const [options, setOptions] = useState(formInput.valueOptions ?? [])
useEffect(() => {
formInput.getValueOptions?.(formInput).then(setOptions)
}, [formInput.id])
…
}Reaching the surrounding form#
Most controllers never need it — that is the point of the two-prop contract. When one genuinely does, useFormContext gives the whole form context.
import { useFormContext } from "react-data-form"
export function DependentController({ formInput, onChange }: InputControllerProps) {
// The surrounding form, when a field genuinely needs it.
const { form, updateData } = useFormContext()
const country = form.inputs.country?.value
…
}Prefer hidden() for conditional fields
Reading a sibling field from inside a controller couples the two. A field's own hidden function does the same job in the description, where it can be read.
Checklist#
- Spread the field back, never replace it.
- Handle
undefined: a create form starts with nothing. - Honour
readonly. - Label the control — the field's
labelis rendered by the group provider, not by you, so a bare<div>of buttons still needs its own accessible name. - Submit the value in the shape your API wants. A controller is free to show hours and store seconds, as the duration one does.