#18182·payload

Array fields: typing lag scales with the number of mounted rows. Collapsed rows mount all their fields, and every row label walks the whole form state on each keystroke

Author: mrobstCreated Sep 15, 2026Updated Sep 15, 2026
LabelsBug

Describe the Bug

In the admin edit view, typing into any field on a document with a large array becomes very slow, and the slowdown is linear in the number of array rows mounted. We traced it to two independent causes in @payloadcms/ui. Each has a one-line fix, and together the two fixes remove almost all of the cost. Measured numbers and the mechanism are below, and we're happy to open a PR.

Cause 1: collapsed array rows mount all their fields. DocumentFields passes forceRender to its fields, and Tabs, Row and Array pass it on. RenderIfInViewport renders immediately when forceRender is set, so the viewport laziness never applies. ArrayRow always passes <RenderFields> as the collapsible's children, and a collapsed row only hides them (AnimateHeightdisplay: none). With every row collapsed and nothing visible, a 171-row array still mounted ~51,000 DOM nodes, ~1,700 inputs and ~690 react-select instances. Each mounted relationship field also fetches its labels on mount, which meant 171 requests on page load.

Cause 2: every row label re-walks the entire form state on every keystroke. RowLabel always wraps its label in RowLabelProvider, whether it's a custom RowLabel component or the default label. The provider calls useWatchForm() and then getSiblingData(path) and getDataByPath(path) on every render. Each of those walks the whole field-state map. FormWatchContext receives a new object on every Form render, and the form re-renders on every keystroke. So each keystroke costs mounted rows × 2 × all fields in the form, even when the keystroke is in an unrelated field.

Both causes are present on 3.x (3.89.0, be896e3eec) and on main (4.0.0-canary.14).

Measurements

Minimal reproduction (linked below): production build, Payload 3.89.0, Next.js 16.3.3, React 19.2.6, default row labels, one document with 170 array rows (each with one nested child row and a hasMany relationship), every row collapsed. 12 synthetic keystrokes 120 ms apart, measured to the next animation frame.

Unpatched With both fixes below
DOM nodes on load 41,017 10,076
Inputs + textareas / react-select instances 1,364 / 340 4 / 0
Array rows mounted 340 (170 items + 170 collapsed children) 170 (item headers only)
Relationship label requests on load 170 0
Main-thread long tasks during load 3.9 s 1.1 s
Typing in an unrelated text field 203 ms / keystroke 17 ms
Typing in an opened child row 268 ms / keystroke 43 ms

Our real data, for scale. Two real documents in one collection. Each row has ~15 fields, including a select, two hasMany relationship fields and a nested array. All rows are collapsed (initCollapsed: true). The collection uses custom RowLabel components that read useRowLabel(). Cause 2 doesn't depend on them, because RowLabelProvider also wraps the default label, and the minimal reproduction above confirms it with default labels.

  • Document A: one top-level array row containing a nested array of 171 rows. About 4,000 fields in form state.
  • Document B: 16 top-level rows with nested arrays totalling 94 rows (up to 20 in one row).

Method: 12 synthetic keystrokes 120 ms apart in a top-level text field (not useAsTitle, to avoid #17834). Per-keystroke time is measured to the next animation frame. Every comparison was made in the same session. ⚠️ These were measured with next dev, which inflates absolute times several-fold. On a production build, Document A unpatched is ~0.8 s per keystroke rather than ~3.2 s. The ratios are the finding.

Document State Unpatched + fix 1 (collapsed rows render nothing) + fix 1 + fix 2
A all rows collapsed 3,192 ms 91 ms 42 ms
A the one parent row open (171 collapsed child rows mounted) —¹ 776 ms 71 ms
A one child row open, typing inside it —¹ 965 ms 201 ms
B all rows collapsed 1,454 ms 158 ms 53 ms
B largest row open (20 child rows) —¹ 192 ms 28 ms
B one child row open, typing inside it —¹ 216 ms 78 ms

¹ Without fix 1 every row is always mounted, so these states cost the same as the first row for that document.

Page load for Document A went from 51,300 to 880 DOM nodes and from 171 relationship-label requests to 0. Main-thread long tasks during load dropped from 29.4 s to 2.8 s.

Two controls isolated the cause. Both were on Document A's data, with fix 1 applied and 171 collapsed child rows mounted:

Control Per keystroke
Normal 720 ms
Form-state server action held (never sent) during typing 684 ms → the round trip and merge are not the cost
Same form state, parent row collapsed so the 171 row headers unmount 73 ms → the cost is the mounted row headers (~3.8 ms each)

Link to the code that reproduces this issue

https://github.com/Digital-Republic-Group/payload-array-rows-repro (start with REPRO.md)

Reproduction Steps

The linked repo is [email protected] -t blank (SQLite) plus one collection, src/collections/Pages.ts, and a seed script. Default row labels, no custom components.

  1. Clone the repo, then cp .env.example .env and pnpm install.
  2. pnpm payload run src/seed.ts. This creates [email protected] / test and one page with 170 items, each with one children row and one related value.
  3. pnpm build && pnpm start, log in, and open Pages → "Large array (170 rows)". Every row is collapsed.
  4. Type into Probe. On a production build each keystroke blocks the main thread for ~200 ms (~17 ms with both fixes below). The DOM contains the fields of every collapsed row, and one relationship-label request fires per row on load.
  5. Without expanding anything, turn on "Highlight updates" in React DevTools: every array row's label re-renders on each keystroke in Probe.

The collection, for reference:

typescript
{
  slug: 'pages',
  fields: [
    { name: 'title', type: 'text' },
    { name: 'probe', type: 'text' }, // type here
    {
      name: 'items',
      type: 'array',
      admin: { initCollapsed: true },
      fields: [
        { name: 'label', type: 'text' },
        { name: 'kind', type: 'select', options: ['a', 'b', 'c'] },
        { name: 'notes', type: 'textarea' },
        { name: 'enabled', type: 'checkbox' },
        { name: 'related', type: 'relationship', relationTo: 'pages', hasMany: true },
        { name: 'children', type: 'array', admin: { initCollapsed: true }, fields: [{ name: 'text', type: 'textarea' }] },
      ],
    },
  ],
}

Proposed fix

Fix 1: don't render a row's fields while it is collapsed (fields/Array/ArrayRow.tsx):

typescript
{isLoading ? <ShimmerEffect /> : row.collapsed ? null : (
  <RenderFields /* …unchanged… */ />
)}

Field values live in server-built form state, so unmounted fields keep their values. RenderIfInViewport already relies on that. Row error counts come from errorPaths in form state, so error pills still show on collapsed rows. BlocksRow has the same pattern, but we didn't measure it. An alternative is to stop forceRender from bypassing viewport laziness for collapsed content. Your call on which is cleaner.

Fix 2: don't subscribe RowLabelProvider to the per-keystroke watch context (forms/RowLabel/Context/index.tsx). What we measured was simply:

typescript
const { getDataByPath, getSiblingData } = useForm() // was: useWatchForm()

FormContext is stable across keystrokes, and the getters read contextRef.current.fields at call time, so the data is current whenever the label renders. Trade-off we observed: a row's label no longer updates live while you type into that same row's label source field. It catches up the next time the row re-renders. This applies only to custom RowLabel components that read useRowLabel(). With the default label there is no visible difference, because it reads no row data. The reproduction confirms this. A fuller fix would subscribe each label only to its own row's fields, so it re-renders when those change and never re-walks the whole form.

Correctness checks, with both fixes applied: row labels rendered the correct text on both documents (verified against the API response), and the trade-off above was the only behaviour difference we found.

Which area(s) are affected?

area: ui. It reproduces on both db: sqlite (the linked repo) and db: mongodb (our real data), so it isn't database-specific.

Environment Info

Binaries:
  Node: 22.20.0
  npm: 10.9.3
  pnpm: 9.15.9
Relevant Packages:
  payload: 3.89.0
  next: 16.3.3
  @payloadcms/db-sqlite: 3.89.0
  @payloadcms/drizzle: 3.89.0
  @payloadcms/graphql: 3.89.0
  @payloadcms/next/utilities: 3.89.0
  @payloadcms/richtext-lexical: 3.89.0
  @payloadcms/translations: 3.89.0
  @payloadcms/ui/shared: 3.89.0
  react: 19.2.6
  react-dom: 19.2.6
Operating System:
  Platform: linux
  Arch: x64
  Available CPU cores: 8

Minimal reproduction: payload / @payloadcms/ui / @payloadcms/db-sqlite 3.89.0, Next.js 16.3.3, React 19.2.6, production build. Real-data measurements: 3.83.0 with @payloadcms/db-mongodb, Next.js 15.4.11, next dev. The same code is on 3.89.0 (3.x @ be896e3eec) and main (4.0.0-canary.14).

Related

  • #6134: large array fields make typing "erratic and unresponsive" (2024, closed without a repro).
  • #11650: performance issue with a ~1,000-element array (closed; a feature request was invited).
  • #13753: the form-state server action posts the full form state. Separate from this issue: in our measurements that round trip was not what blocked typing.