Virtualized lists
Keep values and validation for rows that are not mounted.
A table with hundreds or thousands of editable rows is too slow to render in full. Virtualization renders only the rows in view and unmounts the rest as the user scrolls. React Hook Form works with this, as long as you account for what happens to rows that are not mounted.
What happens to unmounted rows
- Values are kept. With the default
shouldUnregister: false, a row's value stays in the form when its input unmounts, and the input shows it again when it scrolls back into view. Don't setshouldUnregister: truein a virtualized form, or every value scrolled out of view is lost. - Built-in rules don't run. Rules passed to
register, such asrequiredormin, only run for mounted inputs. An invalid row the user never scrolled to would pass validation. - Focus can't reach them.
shouldFocusErrorcannot focus an input that is not in the DOM.
So validate the values with a schema resolver, which checks every row whether it is mounted or not, and scroll to the first invalid row yourself.
Example
This example uses react-window 2 and useFieldArray, with Zod for validation:
npm install react-window zod @hookform/resolvers
import { useState } from "react"import {useFieldArray,useForm,useFormState,type Control,type FieldArrayWithId,type FieldErrors,type UseFormRegister,} from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { List, useListRef, type RowComponentProps } from "react-window"import { z } from "zod"const schema = z.object({items: z.array(z.object({sku: z.string(),title: z.string(),quantity: z.number({ message: "Enter a quantity." }).int("Use a whole number.").min(0, "Use 0 or more."),})),})type InventoryValues = z.infer<typeof schema>type RowProps = {fields: FieldArrayWithId<InventoryValues, "items">[]register: UseFormRegister<InventoryValues>control: Control<InventoryValues>}function Row({index,style,ariaAttributes,fields,register,control,}: RowComponentProps<RowProps>) {const name = `items.${index}.quantity` as const// Each row subscribes to its own error only.const { errors } = useFormState({ control, name })const error = errors.items?.[index]?.quantityreturn (<div style={style} {...ariaAttributes}><label htmlFor={name}>{fields[index].title}</label><inputid={name}type="number"aria-invalid={!!error}{...register(name, { valueAsNumber: true })}/>{error && <span role="alert">{error.message}</span>}</div>)}function firstErrorIndex(errors: FieldErrors<InventoryValues>) {const items = errors.itemsif (!Array.isArray(items)) return -1return items.findIndex(Boolean)}export default function InventoryForm({items,onSubmit,}: {items: InventoryValues["items"]onSubmit: (values: InventoryValues) => void}) {const listRef = useListRef(null)const [summary, setSummary] = useState("")const { register, control, handleSubmit } = useForm<InventoryValues>({resolver: zodResolver(schema),defaultValues: { items },})const { fields } = useFieldArray({ control, name: "items" })const onInvalid = (errors: FieldErrors<InventoryValues>) => {const index = firstErrorIndex(errors)const count = Array.isArray(errors.items)? errors.items.filter(Boolean).length: 0setSummary(`${count} rows need attention.`)// The row may be off screen, so scroll to it before the user fixes it.if (index !== -1) listRef.current?.scrollToRow({ index, align: "center" })}return (<form noValidate onSubmit={handleSubmit(onSubmit, onInvalid)}><ListlistRef={listRef}rowComponent={Row}rowCount={fields.length}rowHeight={48}rowProps={{ fields, register, control }}style={{ height: 480 }}/>{summary && <p role="alert">{summary}</p>}<button type="submit">Save inventory</button></form>)}
How it fits together:
useFieldArrayprovides a stablefield.idand thetitlefor each row. The input itself is registered by path, so the value lives in the form, not in the row component.- Each row calls
useFormStatefor its own field, so a new error re-renders only that row, and only while it is mounted. - On a failed submit,
onInvalidreceives the errors for every row, including unmounted ones. It announces how many rows need attention and scrolls the first one into view. - On a valid submit,
handleSubmitreceives all 1,000 rows, with the edited values.
Things to watch
- Use dot paths. Register
items.0.quantity, notitems[0].quantity. The root ofdefaultValuesmust be an object, so wrap the array in a field such asitems. - Keep rows light. Pass
registerandcontrolto rows rather than readingformStatein the form component, so scrolling and typing don't re-render the whole list. See Isolating form subscriptions. - Controlled inputs. A
Controllerin a row also keeps its value after unmounting. Don't pass a per-rowdefaultValuethat would override what the user typed. Put the initial data inuseForm'sdefaultValuesinstead. - Adding and removing rows. Use the field array methods (
append,remove, and so on), and passfields.lengthas the row count so the list stays in sync.