Contents

Making React Forms Fast With Thousands of Rows

Progressive rendering, lazy component trees, memoization, and responsive interactions at scale

/images/react-large-list-performance.png

An API can return in a few hundred milliseconds while the browser spends seconds constructing components, running hooks, calculating layout, and painting. I encountered this with an editable form containing several thousand rows: saving was expensive on the backend, but displaying and editing exposed a separate client-side problem. The examples intentionally generalize the dataset size and domain.

The fix was to reduce how much work React and the browser had to do.

Separate the symptoms

The page paused while mounting the list. Opening “view all” and filtering were slow, unrelated form edits could redraw every row, and dragging caused repeated updates. Thousands of hidden edit modals also consumed memory although only one could open.

These symptoms had different causes. The component tree contained one row and one pair of drag-and-drop hooks per mounted entry; browser traces showed main-thread and layout work when the full list opened.

Rendering everything does not scale

The initial implementation was effectively:

<List>
  {items.map((item, index) => (
    <EditableRow key={index} item={item} index={index} />
  ))}
</List>

For a short list, this is fine. At several thousand items, it executes every row component, installs thousands of drag-and-drop hooks, creates thousands of DOM nodes, and lays out content outside the viewport. The virtual DOM does not make the browser’s real DOM work cheap.

Virtualization: evaluated, not retained

A virtualizer mounts only visible rows and a small overscan area, reducing initial work, DOM size, layout cost, memory, and parent-driven updates.

Editable lists make this harder. Validation can change row heights; drag-and-drop libraries may expect mounted targets; filtering separates visible and source indices; and scrolling to a new row must wait for it to exist. An implementation must also preserve focus, keyboard use, and screen-reader behavior when rows are recycled.

Full virtualization remains the best option for some screens. Here, progressive mounting offered a simpler trade-off.

Implemented: progressive mounting

The list used batched rendering: mount an initial set, then schedule additional batches with requestAnimationFrame.

useEffect(() => {
  if (renderLimit >= entries.length) return;

  const frameId = requestAnimationFrame(() => {
    setRenderLimit((current) =>
      Math.min(current + batchSize, entries.length)
    );
  });

  return () => cancelAnimationFrame(frameId);
}, [renderLimit, entries.length, batchSize]);

This avoids one enormous synchronous mount but, unlike virtualization, does not reduce the eventual DOM. requestAnimationFrame schedules each batch before a future repaint, which can allow paints between commits when batches stay small; it is not a general-purpose yielding guarantee.

Batching fits when all rows must eventually exist for native page behavior or full-list drag-and-drop, and first interaction matters more than final DOM size. It is an intermediate architecture, not a replacement for virtualization at arbitrary scale.

Implemented: lazy per-row edit modals

Each row originally owned an edit modal containing another form.

Closed modals are still expensive when their children remain mounted. Several thousand rows meant thousands of form instances, field trees, validation schemas, refs, and event handlers, although only one modal could be used.

A lazy guard removes most of that fixed cost:

{mountModal && (
  <EditModal
    ref={modalRef}
    initialValue={item}
    onSave={handleSave}
  />
)}

A shared modal outside the list is a further recommendation, not the current implementation:

<EditableList onEdit={setSelectedItem} />

{selectedItem && (
  <EditModal
    item={selectedItem}
    onClose={() => setSelectedItem(null)}
  />
)}

If only one instance can be visible, thousands should not be mounted.

Implemented: memoized rows and stable inputs

Wrapping rows in React.memo can prevent unchanged rows from rendering:

const EditableRow = React.memo(function EditableRow(props) {
  // ...
});

React.memo skips a parent-driven render only when every prop is Object.is-equal to its previous value. It does not block renders caused by the row’s own state or consumed context. It also provides little value when the parent creates new objects and functions on every render:

<EditableRow
  item={{ ...item }}
  onRemove={() => remove(index)}
/>

Both props have a new identity each time. Memoization helps when unchanged item objects retain identity, callbacks stay stable, and shared configuration is not recreated per row. One unstable callback can invalidate thousands of memoized children.

Partly implemented: shared configuration through context

Rows often need the same schemas, labels, options, serialization functions, and autocomplete settings. Passing all of them through every row enlarges the prop surface and makes memoization fragile.

A context can define the shared component boundary:

<ItemConfigurationContext.Provider value={configuration}>
  <LargeList entries={entries} />
</ItemConfigurationContext.Provider>

Context reduces prop plumbing but does not create a memoization boundary. A new provider value updates consumers, so retain or memoize the object only while its contents remain unchanged.

Compute derived data once

Display labels may combine several fields. Filtering should not rebuild them for every item on every keystroke, so normalized search data is computed when the source list changes:

const searchableEntries = useMemo(
  () =>
    items.map((item, index) => {
      const displayName = getDisplayName(item);

      return {
        item,
        index,
        displayName,
        searchName: normalizeSearch(displayName),
      };
    }),
  [items]
);

const filteredEntries = useMemo(() => {
  if (!query) return searchableEntries;

  return searchableEntries.filter(({ searchName }) =>
    searchName.includes(query)
  );
}, [searchableEntries, query]);

This leaves expensive transformation to data changes and cheap substring matching to query changes. Normalization can also make filtering case- and diacritic-insensitive.

The important part is matching dependencies to the data flow, not useMemo itself. This assumes immutable updates: mutating the array or entries in place may leave [items] unchanged and the memoized data stale.

Implemented: keep presentational scroll state out of React state

Scroll events can fire dozens of times per second. Updating React state each time can redraw the list merely to toggle a visual affordance.

For scroll-edge indicators, a local DOM class update can be sufficient:

useEffect(() => {
  const element = scrollRef.current;
  const wrapper = wrapperRef.current;
  if (!element || !wrapper) return;

  const sync = () => {
    wrapper.classList.toggle("can-scroll-up", element.scrollTop > 0);
    wrapper.classList.toggle(
      "can-scroll-down",
      element.scrollTop < element.scrollHeight - element.clientHeight
    );
  };

  sync();
  element.addEventListener("scroll", sync, { passive: true });
  return () => element.removeEventListener("scroll", sync);
}, []);

Recommendation: buffer drag-and-drop updates

A drag hover handler may reorder the form array every time the pointer enters another row:

hover(draggedItem) {
  moveItem(draggedItem.index, hoverIndex);
  draggedItem.index = hoverIndex;
}

Each move can update form state and redraw much of the form, turning one gesture into many expensive transitions. Keep temporary order locally and commit on drop, update only after crossing a row’s midpoint, or throttle hover moves. Very large lists may need button or keyboard reordering, and filtered views may need reordering disabled. Continuous preview state and committed form state need not be the same.

Recommendation: replace index keys

Using an array index as a React key is tempting:

<Row key={index} />

After insertion, deletion, filtering, or movement, index keys no longer identify the same logical items. React may reuse component state for the wrong item and update shifted rows; the primary risk is incorrect identity, not that every shifted row necessarily remounts.

A stable domain identifier is better:

<Row key={item.id ?? item.clientKey} />

If unsaved items lack IDs, assign a unique clientKey once when they enter client state. Keys must be unique among siblings and never generated during render. Validation errors can still use indexed form paths; React keys and form paths solve different problems.

Recommendation: separate viewing from management

A “view all” action can accidentally mount rows, drag-and-drop, add and import forms, validation logic, a modal portal, and event handlers when the user only wants to read or search.

A lighter view could open a searchable list immediately, then mount editing controls or forms only when requested. Narrower component responsibilities create a cheaper default path.

A practical order for fixing large React lists

Start with a real interaction and locate the largest cost. Remove expensive hidden children, then reduce the initial row count through virtualization or batching. Once mounting is under control, memoize rows, stabilize inputs, and precompute searchable values. Move high-frequency visual and drag-preview state out of the main form path, and fix keys where identity matters. Measure again after each meaningful change; memoization saves little if thousands of complex rows still mount at once.

The full-stack connection

Large collections create work on both sides of the network. The backend pays for parsing, sanitization, validation, copying, and serialization; the frontend pays for component construction, hooks, layout, and interaction state. Optimizing one can expose the other.

The Python side of this case study is covered in Profiling and Optimizing Python Serialization Pipelines.