Skip to content

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 set shouldUnregister: true in a virtualized form, or every value scrolled out of view is lost.
  • Built-in rules don't run. Rules passed to register, such as required or min, only run for mounted inputs. An invalid row the user never scrolled to would pass validation.
  • Focus can't reach them. shouldFocusError cannot 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]?.quantity
return (
<div style={style} {...ariaAttributes}>
<label htmlFor={name}>{fields[index].title}</label>
<input
id={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.items
if (!Array.isArray(items)) return -1
return 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
: 0
setSummary(`${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)}>
<List
listRef={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:

  • useFieldArray provides a stable field.id and the title for each row. The input itself is registered by path, so the value lives in the form, not in the row component.
  • Each row calls useFormState for its own field, so a new error re-renders only that row, and only while it is mounted.
  • On a failed submit, onInvalid receives 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, handleSubmit receives all 1,000 rows, with the edited values.

Things to watch

  • Use dot paths. Register items.0.quantity, not items[0].quantity. The root of defaultValues must be an object, so wrap the array in a field such as items.
  • Keep rows light. Pass register and control to rows rather than reading formState in the form component, so scrolling and typing don't re-render the whole list. See Isolating form subscriptions.
  • Controlled inputs. A Controller in a row also keeps its value after unmounting. Don't pass a per-row defaultValue that would override what the user typed. Put the initial data in useForm's defaultValues instead.
  • Adding and removing rows. Use the field array methods (append, remove, and so on), and pass fields.length as the row count so the list stays in sync.