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.

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.

Verification Checklist

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