Building complex and interactive forms for React
How I built a complex form for React (with react-hook-forms). An introduction and learned best practices on how some common complex form usecases might be handled.
Mapping out the requirements
Recently I had to build quite a complicated form system with the following requirements:
The form had multiple stages ("Wizard Form"), where appearance of each stage depended on previous values
Inputs had to be conditionally shown or hidden based on a feature flag
Filling certain inputs had to send an HTTP request, whose result populated other fields in the form
The form had a corresponding Typescript type; validation against it was a nice add
The whole thing had to be validated against a JSON schema
Handle arrays of items without much trouble
These pose a relatively big challenge for a few reasons: feature flags and api fetches require dynamically toggling parts of the form on or off, supporting schemas and types can be hard to juggle with the dynamic state of the form, and arrays can get quite complicated with the amount of operations they need to support.
In general, the dynamic state of the form combined with the need for some inputs to depend on other inputs’ values meant that form state should be managed centrally. Something like Redux, but in smaller, more focused form.
react-hook-form (RHF) is a library that satisfies the needs for flexibility and simplicity.
Start from schema first
Mapping out the final form in a JSON schema form at first helps a lot: you’ll get nicer validation error messages and you can be sure that the final data structure matches expected.
Handling react-hook-form errors consistently
Handling errors consistently is simple in theory: each HTML input element takes attributes to control what kind of values it accepts. For instance, numeric fields have min attribute for the allowed minimum value and textual fields have minlength for the minimum length.
Turns out that these are not even close to being enough: some fields have specific requirements that must be specified as javascript logic and some fields might even depend on other fields for validation (e.g signature field must use a custom JS function to check whether the signature has been given or a composite datetime field that requires individual date component fields all to be valid).
function InputField<FV extends FieldValues, Path extends FieldPath<FV>>({ control, name, label, children,}: { control: Control<FV>; name: Path; label: string; children: (props: { name: Path; id: string }) => React.ReactNode;}) { const { errors } = useFormState({ control }); const error = get(errors, name); const id = `form-${String(name)}`; return ( <> <label className="block" htmlFor={id}> {label} </label> {children({ name, id })} <div className={`bg-red-200 p-1 ${error ? "visible" : "invisible"}`}> {error && error.message} </div> </> );}
<InputField name="username" label="Username"> {({ name, id }) => ( <input id={id} {...register(name)} /> )}</InputField>
gives
The InputField
helper makes it easy to insert input fields that have a) labels with correct ids, b) automatically shown error message, c) pre-reserved space for error message (notice that the helper uses Tailwind’s visible
/invisible
classes instead of block
/hidden
, meaning that even when there is no error message the space is reserved)
Combining server-side schema validation
Next challenge is handling schema validation: we want there to be a single source of truth JSON schema that is used both for validating submitted user input on the server and validating the user data on client for immediate feedback.
RHF comes with validation resolvers that use an external validation library to validate form contents and then map possible errors from the validator to RHF compatible error objects. This works perfectly for the schema validation usecase. Our choice of JSON schema validation library ajv can be used in react-hook-form with useForm({ resolver: ajvResolver(schema) })
. Combined with our consistent error handling helper, this means that every schema validation error will be cleanly shown directly under the affected input.
Only having a single source of truth schema still remains a problem. One way to solve this with Next.js is either dynamically sending the schema to client or importing it directly to the page where the form is located. Here we implement it by dynamically sending the schema.
// /pages/api/handleFormSubmit.tsexport const SCHEMA = { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, required: ["name", "age"], additionalProperties: false,} as const;const ajv = new Ajv();const validate = ajv.compile(SCHEMA);export default async function handler( req: NextApiRequest, res: NextApiResponse) { const body = req.body; if (!validate(body)) { return res.status(400).send("invalid body"); } // do whatever with validated body}
// /pages/form.tsxexport default function Page({ schema }) { const form = useForm({ resolver: ajvResolver(schema) }); // we have a form with identical schema validation as serverside}import { SCHEMA } from "./api/handleFormSubmit.ts"export const getServerSideProps: GetServerSideProps< { error: Record<string, any> } | Props> = async (ctx) => { return { props: { schema: SCHEMA, }, };};