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#

ColorInputController.tsx
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:

NameTypeDefaultDescription
formInput.valueT | undefinedThe current value. Always handle undefined.
formInput.readonlybooleanDisable the control. useForm drops the change anyway, but an enabled control that ignores clicks is worse than a disabled one.
formInput.placeholder / descriptionstringPassed through to whatever you render.
formInput.valueOptions / getValueOptions / onSearchoptionsFor 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 label is 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.