Keep a form of 100 to 500 fields at 60fps by making inputs uncontrolled, reading their values through per-field subscriptions, windowing the rendered rows, and deferring initialization of fieldsets the user has not yet scrolled to.

Context

This is the concrete rendering technique behind performance and scale for large forms, which explains the render budget and the subscription store this page builds on. The parent covers why a controlled form re-renders every field on one keystroke; here we build the windowed, uncontrolled renderer that keeps mount cost and reconciliation inside a frame budget. The value-ownership decision underneath it all is covered in controlled vs uncontrolled forms — for very large forms, uncontrolled inputs win because they take per-keystroke reconciliation off the table entirely.

Core Pattern

The renderer has three cooperating parts: an uncontrolled input that writes to a store but never binds value back, a windowing hook that decides which rows are mounted, and a store snapshot that submission reads from. Values live in the store, keyed by a stable field id, so a row can unmount and remount without losing data.

// A windowed, uncontrolled form renderer. Inputs write to the store on change
// but never read `value` back, so a keystroke does not trigger React reconciliation.
interface FieldStore {
  get(id: string): string;
  set(id: string, value: string): void;
  snapshot(): Record<string, string>;
}

interface FieldDef { id: string; label: string; }

// The windowing hook returns the slice of fields to mount for the current
// scroll position, plus the spacer heights that keep the scrollbar honest.
function useWindow(total: number, rowHeight: number, viewportH: number, scrollTop: number) {
  const overscan = 4; // rows rendered beyond each edge to hide scroll blanking
  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const visibleCount = Math.ceil(viewportH / rowHeight) + overscan * 2;
  const last = Math.min(total, first + visibleCount);
  return {
    first,
    last,
    padTop: first * rowHeight,               // spacer above the mounted rows
    padBottom: (total - last) * rowHeight,   // spacer below, preserves scroll range
  };
}

// One uncontrolled field. defaultValue seeds the input from the store on mount;
// after that the DOM owns the value and onChange mirrors it back to the store.
function Field({ def, store }: { def: FieldDef; store: FieldStore }) {
  return (
    <label style={{ display: 'block' }} data-field-id={def.id}>
      <span>{def.label}</span>
      <input
        name={def.id}
        // defaultValue (not value) => uncontrolled. React does not re-render
        // this input on keystroke; the store write below is fire-and-forget.
        defaultValue={store.get(def.id)}
        onChange={(e) => store.set(def.id, e.currentTarget.value)}
      />
    </label>
  );
}

function WindowedForm({ fields, store, rowHeight = 56, viewportH = 640 }: {
  fields: FieldDef[]; store: FieldStore; rowHeight?: number; viewportH?: number;
}) {
  const [scrollTop, setScrollTop] = React.useState(0);
  const { first, last, padTop, padBottom } = useWindow(
    fields.length, rowHeight, viewportH, scrollTop,
  );

  return (
    <div
      style={{ height: viewportH, overflowY: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* Spacers reserve the full scroll height so the scrollbar matches the
          logical field count even though only a window of rows is mounted. */}
      <div style={{ height: padTop }} />
      {fields.slice(first, last).map((def) => (
        <Field key={def.id} def={def} store={store} />
      ))}
      <div style={{ height: padBottom }} />
    </div>
  );
}

Step-by-Step Walkthrough

  1. Seed the store, then render the window. Initialize the field store with server data (or empty strings) before first paint. The windowing hook computes first/last from scrollTop and only that slice mounts — 12 rows for a 640px viewport, not 500.

  2. Write on change, never bind value back. Each Field uses defaultValue, making it uncontrolled. onChange mirrors the keystroke into the store, but because value is not bound, React never re-renders the input. The store write is O(1) and does not fan out to siblings.

  3. Preserve scroll range with spacers. The padTop and padBottom divs reserve the height of the unmounted rows so the scrollbar reflects all 500 fields. Without them the container would collapse to the height of the mounted window and scrolling would break.

  4. Restore values on remount. When a row scrolls back into view it remounts and defaultValue={store.get(def.id)} re-seeds it from the store. The user’s earlier input is intact because the store, not the DOM node, held it.

  5. Submit from the snapshot. On submit, serialize store.snapshot() rather than building FormData from the form element. The snapshot contains every field; the DOM contains only the mounted window. This mirrors how error state mapping reads from the store to place errors on fields that may not be mounted.

Virtualising a form is not the same as virtualising a list, because a form field that leaves the document takes its value, its validation state and its focusability with it. Three strategies handle that differently:

Three ways to shrink a 300-field form, and what each costs Render everything: all fields stay in the document, submit sees every value, find-in-page and native required validation work, but layout and paint cost grows with field count. Content-visibility auto: all fields stay in the document and are skipped only for rendering, so submit, find-in-page and native validation all still work while layout cost drops sharply; the cost is that scrollbar length jumps unless a size hint is supplied. True virtualisation: off-screen fields are removed from the document, so layout cost is flat regardless of size, but submit no longer sees their values, find-in-page misses them and native required validation cannot reach them, so state must live outside the DOM and focus must be restored explicitly. Strategy Off-screen fields Layout cost What you give up render everything in the document grows linearly nothing content-visibility: auto in the document near flat stable scrollbar, without a size hint true virtualisation removed flat submit, find-in-page, native required Try the middle row first: one CSS declaration, no state migration, and none of the third row's consequences. If you do virtualise, form state must already live outside the DOM — otherwise scrolling past a field silently discards what was typed in it. Add contain-intrinsic-size to every virtualised row so the scrollbar stops resizing as the reader scrolls.

Failure Modes and Edge Cases

Off-screen values lost on submit. Building new FormData(formEl) yields only mounted inputs, silently dropping every windowed-out field.

// WRONG: only the mounted window is in the DOM.
// const data = new FormData(formEl);
// RIGHT: the store holds all fields regardless of what is mounted.
const data = store.snapshot();

Scroll blanking on fast flings. With zero overscan, fast scrolling outruns the render and shows blank rows. Raise overscan to 4–5, and consider rendering rows on scroll with a requestAnimationFrame throttle so state updates coalesce to one per frame.

Variable row heights break the math. The rowHeight constant assumes uniform rows; a field with a validation message is taller, so padTop drifts and rows jump. Measure rendered row heights and store a running offset table, or enforce a fixed row height with the message in a reserved, always-present slot.

Autofocus and jump-to-error miss unmounted fields. Focusing the first invalid field fails if that field is not in the current window. Scroll the virtualizer to the field’s index first, wait one frame for it to mount, then focus — the same ordering focus management after validation requires.

Deferred fieldset init races validation. If you lazily register a collapsed section only when it scrolls into view, a submit that happens before the user reaches that section must still initialize and validate it. Force-initialize all deferred sections in the submit handler before reading the snapshot.

One consequence deserves spelling out, because it turns a performance win into an accessibility regression if it is missed:

Recycling the focused row throws focus to the body Step one: the reader focuses the field in row forty. Step two: an autocomplete popup or a scroll jump moves that row outside the rendered window. Step three: the virtualiser unmounts the row, and because the focused element no longer exists, the browser moves focus to the document body. Step four: the next Tab press starts again from the top of the page, and a screen reader announces the page rather than the form. The fix is to treat the focused row's index as part of the render window so it stays mounted even when it is outside the visible range. The recycled-focus bug, one step at a time 1 · focus row 40 reader is typing in a live field 2 · window moves popup or scroll jump pushes row 40 out 3 · row unmounts focused node is gone focus falls to body 4 · Tab restarts from the top of the whole page The fix: the focused index is part of the render window renderSet = visibleRange ∪ { focusedIndex } — the row stays mounted while it holds focus, whatever the scroll position says. Assert it in a test: focus a field, scroll the container 2000px, and check document.activeElement is still that input. The same union keeps the field the error summary just linked to from being recycled out from under the reader.

Verification Checklist

Coalescing input events

A fast typist produces input events faster than a frame can absorb them, and a form that does one unit of work per event is doing work nobody will ever see. Coalescing to one unit per frame costs nothing in fidelity:

Five events per frame, one paint per frame Top timeline: a fast typist emits about five input events inside a single sixteen millisecond frame, and handling each one synchronously means five state updates, five validations and five renders, of which only the last is ever painted. Bottom timeline: each event writes the latest value to a mutable holder and schedules a single update with requestAnimationFrame, so exactly one update, one validation and one render happen per frame. The reader sees identical output, because a frame can only paint once. One unit of work per event — four of five are wasted frame 1 5 updates, 5 renders frame 2 5 updates, 5 renders frame 3 5 updates, 5 renders 15 renders, 3 paints — 12 renders the reader never saw. One scheduled update per frame — same pixels frame 1 1 update, 1 render frame 2 1 update, 1 render frame 3 1 update, 1 render Keep the DOM value untouched — the field stays responsive because the browser paints keystrokes itself; only your derived state waits.

Frequently Asked Questions

Do uncontrolled inputs lose their value when windowed out of view?

Only if the value lives solely in the DOM. Write each change to a store keyed by field id, so when a row unmounts on scroll its value persists in the store and is restored via defaultValue when the row remounts. The DOM node is disposable; the store is the source of truth. This is what makes uncontrolled inputs safe to virtualize.

How do I submit a virtualized form when most inputs are unmounted?

Do not build FormData from the form element — it only contains mounted inputs, so windowed-out fields are silently dropped. Serialize the store snapshot instead. Every field’s value is in the store regardless of whether its row is currently rendered, so the submitted payload is complete. Force-initialize any lazily-registered sections before taking the snapshot.

What overscan value should I use for a windowed form?

Render two to five rows beyond each edge of the viewport. Too little overscan shows blank space during fast scroll; too much erodes the mount savings that make windowing worthwhile. Tune it against measured scroll performance on your slowest target device rather than a fixed guess, and pair it with a requestAnimationFrame-throttled scroll handler so updates coalesce to one per frame.


Related

Performance and Scale for Large Forms