Quiz - React Hook Form
A teammate proposes moving every form in the app to React Hook Form “so they all work the same way,” starting with the login form (email, password, one submit). Which forms actually justify the reach? Select all that apply.
You add a live 0/280 character counter beside a registered note field. You read the value with const note = form.watch('note') at the top of the form component. It works, but typing anywhere in the form now feels laggy. What’s the fix?
useWatch({ control, name: 'note' }), so only that child re-renders per keystroke instead of the whole form root.useMemo so React skips re-rendering the untouched fields while note changes.note input from register to Controller so RHF stops re-rendering the form on its changes.watch in the form root subscribes the root to that field, so every keystroke re-renders the entire form — the registered inputs included, which is why it lags. Re-render scope in RHF is a subscription-placement problem, not a memoization one: push a useWatch into the leaf that needs the value and only that leaf re-renders. useMemo is the wrong lever (and the course runs the React Compiler anyway), and Controller would make the field controlled — adding re-renders, not removing them.Your InvoiceSchema has total: z.coerce.number<number>().positive(). A teammate types the form as useForm<z.infer<typeof InvoiceSchema>>(), ships it, and it type-checks fine. Why is the senior reviewer still asking for useForm<InvoiceInput, unknown, Invoice> instead?
z.infer is the output type, so it mistypes what register and defaultValues track (the input side) — it compiles today only because the gap is invisible until a transform’s input and output diverge, and coerce already makes them diverge.z.infer is slower at runtime because it re-derives the type on every render; the explicit generics are memoized.defaultValues and register hold) and hands onSubmit the output. z.infer resolves to the output, so the single-generic form types the tracked side wrong — it just happens not to error here, and would silently mislead the moment anyone leans on it. The three-parameter <input, context, output> form is correct from the first line, before any transform exists to expose the shortcut. Generics are types only — they don’t switch the resolver on or off or cost anything at runtime.A line-items form renders rows with key={index}. Add works, and removing the last row works — but when the user deletes a middle row while editing a row below it, their half-typed text and cursor jump to the wrong line. What’s going on?
field.id — RHF’s stable per-row render key — and the survivors keep their nodes.remove(index) mutates the array in place; you need replace with a fresh copy so React sees a new reference and re-renders cleanly.id from the database, so React falls back to matching by position; add id to each row’s defaultValues.useFieldArray exists for the reorderable case, so the index-as-key bug bites on every operation that isn’t append-to-end: the key tracks where a row sits, not which row it is, so a removed middle row makes React keep the old node and stuff the next row’s data into it. field.id is RHF’s stable render identity for exactly this. Note field.id is not the line’s domain id — that one decides insert-versus-update on the server and is never the React key — and remove already re-indexes correctly, so replace is no fix.In your line-items form, a bad amount on row 2 renders fine through that row’s Controller, but the schema’s .min(1, 'Add at least one line') rule never shows when the user empties the list. You’re reading form.formState.errors.lineItems?.message. Where does that array-level error actually live?
form.formState.errors.lineItems?.root — array-level rules report on the field’s root slot, not on .message and not on any row.form.formState.errors.lineItems?.[0]?.message — the array error attaches to the first row, so reading index 0 surfaces it.form.formState.errors.root?.lineItems — whole-array errors hang off the form’s top-level root, keyed by field name.errors.lineItems[index].field — but you rarely read that raw, because each row’s Controller hands you its own fieldState.error. The array-as-a-whole rule (.min(1)) attaches to neither a row nor .message; RHF parks it at errors.lineItems.root. Reading .message or index 0 is exactly why the “add at least one line” message silently never appears.In a three-step invoice wizard, clicking Next on step 1 should validate only step 1’s fields (customer, email) and advance if they pass. Which call does that correctly?
await form.trigger(fieldsForStep(1)), advancing if it returns true — trigger runs the resolver against just the named fields.form.handleSubmit(goNext) — it validates, then advances on success.form.formState.isValid is true.handleSubmit always validates the whole schema, so it fails on steps 2 and 3’s empty fields and never lets you off step 1 — it’s the tool for the final submit. isValid is the same whole-form scope: false until every step passes. Only trigger(fieldNames) runs the resolver against a subset; it’s async, so you await it (skip the await and you branch on a truthy promise and the gate always passes). Two scopes, two APIs: trigger for Next, handleSubmit for the end.A wizard uses conditional rendering ({step === 1 && <CustomerStep />}) and sets shouldUnregister: true on useForm “to keep form state tidy.” A user fills step 1, advances, then clicks Back — step 1’s fields are blank. What happened?
CustomerStep, and shouldUnregister: true drops an unmounting field’s value from form state — so Back remounts empty. The default (false) keeps the values; remove the line.Back calls setStep(step - 1), which re-runs useForm and resets every field to its defaultValues. Guard the reset behind a useRef.useForm so its values persist independently; the shared root form can’t hold a hidden step’s data.shouldUnregister decides whether their values survive. The default is false — unmounted fields keep their values, so Back repopulates — which is exactly what a wizard wants; true drops them and ships the data-loss bug. Note the per-step useForm idea is the opposite of right: one useForm at the root, shared via FormProvider, is what keeps every step’s data alive in the first place.Quiz complete
Score by topic