# Virtualized lists

> Use React Hook Form with virtualized lists such as react-window, keeping values for unmounted rows with shouldUnregister false and useFieldArray.

Source: https://react-hook-form.com/advanced-usage/virtualized-lists

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](https://react-hook-form.com/integrations/resolvers), which checks every row whether it is mounted or not, and scroll to the first invalid row yourself.

## Example

This example uses [react-window](https://github.com/bvaughn/react-window) 2 and [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray), with Zod for validation:

```bash
npm install react-window zod @hookform/resolvers
```

```tsx
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`](https://react-hook-form.com/docs/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](https://react-hook-form.com/advanced-usage/isolating-subscriptions).
- **Controlled inputs.** A [`Controller`](https://react-hook-form.com/docs/usecontroller/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.
