How would you manage form validation, error handling, and state for dynamic inputs
Use a form library (React Hook Form) for state, validation, and field arrays rather than hand-rolling. Validate with a schema (Zod/Yup). Track per-field touched/dirty/error; validate on blur + submit. For dynamic inputs use field arrays with stable ids. Always re-validate on the server.
Forms with dynamic inputs hit three hard problems at once — state, validation, dynamic field arrays — and hand-rolling all three is where bugs live.
Use a form library
For anything non-trivial, reach for React Hook Form (or Formik). It gives you:
- Per-field state without re-rendering the whole form on every keystroke (uncontrolled + refs under the hood).
touched,dirty,isValid,isSubmitting, per-fielderror.useFieldArray— purpose-built for dynamic add/remove inputs.
Validation — use a schema
Define rules declaratively with Zod or Yup, plugged into the form via a resolver:
const schema = z.object({
email: z.string().email(),
items: z.array(z.object({ name: z.string().min(1), qty: z.number().positive() })).min(1),
});One schema is the single source of truth — and the same schema can validate on the server. Schema validation beats scattered inline if checks.
When to validate (UX matters)
- On blur for the first validation of a field — don't scream at the user mid-typing.
- On change after a field has errored once — so they see it clearing as they fix it.
- On submit — validate everything, focus the first invalid field.
- Disable submit while
isSubmitting; show a spinner.
Dynamic inputs — field arrays
For "add another item" rows, use useFieldArray. The critical detail: stable keys. The library gives each row a generated id — use that as the React key, never the array index, or removing a middle row corrupts which input shows which value/error.
const { fields, append, remove } = useFieldArray({ name: "items", control });
fields.map((field, i) => <Row key={field.id} index={i} />); // field.id, not iError handling
- Field-level errors inline next to the input, with
aria-describedby/aria-invalidfor accessibility. - Form-level / submit errors (server rejected, network failure) shown at the top, and map server validation errors back onto the right fields (
setError("email", ...)). - Keep the user's input on failure — never wipe the form.
The non-negotiable
Client validation is UX; the server must re-validate. Client checks are bypassable — the same schema runs server-side as the real gate.
The framing
"I don't hand-roll it — React Hook Form for state and useFieldArray for the dynamic rows, plus a Zod schema as the single source of validation truth that also runs on the server. Validate on blur first, then on change once a field has errored, and everything on submit with focus to the first error. The dynamic-input trap is keys: use the library's stable field id, never the index, or removing a row scrambles values and errors. Server errors get mapped back onto their fields, and the user's input is never lost on failure."
Follow-up questions
- •Why use the field array's id instead of the index as the key?
- •When should validation fire — on change, blur, or submit?
- •How do you map server-side validation errors back to fields?
- •Why re-validate on the server if the client already validated?
Common mistakes
- •Using the array index as the key for dynamic rows — corrupts state on remove.
- •Validating aggressively on every keystroke from the start — hostile UX.
- •Scattered inline if-checks instead of one schema.
- •Treating client validation as sufficient — skipping server validation.
- •Wiping the form on a failed submit.
Performance considerations
- •Controlled forms re-render on every keystroke; React Hook Form's uncontrolled/ref approach isolates re-renders to the changed field — important for large or dynamic forms. Debounce async validators to avoid request spam.
Edge cases
- •Removing a middle row from a dynamic field array.
- •Cross-field validation (confirm password, end-date after start-date).
- •Async validation (username availability) with debounce.
- •Server rejects with field-specific errors after client passed.
Real-world examples
- •An invoice form with a dynamic list of line items via useFieldArray.
- •Signup forms using a Zod schema shared between client and API.