Back to blog
Article

Sixty-field admission forms and the re-render cost per keystroke

Sixty-field admission forms and the re-render cost per keystroke
S

StriveBit

3 min readWeb Applications

Sixty-field admission forms and the re-render cost per keystroke

A school admission form has 62 fields across six sections. On a Pixel 4a over a 3G connection, typing a parent's name made the email field lag by roughly 400ms per character. The form was a single React component holding one useState object, so every keystroke rebuilt the entire component subtree and re-ran six sections' worth of field validation.

The fix was to stop treating the form as one component.

We split each section into its own component with its own local state. The top-level component no longer holds field values — it holds section completion flags and a submit handler. A keystroke in the "parent details" section now re-renders only that section's five fields, not all 62.

function ParentSection({ onComplete }: { onComplete: (data: ParentData) => void }) {
  const [values, setValues] = useState<ParentData>(initialParentData);

  const update = (key: keyof ParentData, val: string) => {
    setValues(prev => ({ ...prev, [key]: val }));
  };

  return (
    <div>
      <Input
        value={values.fatherName}
        onChange={v => update('fatherName', v)}
      />
      <Input
        value={values.motherName}
        onChange={v => update('motherName', v)}
      />
      <button onClick={() => onComplete(values)}>Next</button>
    </div>
  );
}

Validation moved to a debounce. Running six regex checks and a date comparison on every keystroke was costing 80-120ms on its own, and it ran even when the user had only typed half a name. We wrapped validation in a 600ms debounce and cleared it on unmount. The user sees the error after they pause typing, not while they are still mid-word.

useEffect(() => {
  const timer = setTimeout(() => {
    setErrors(validate(values));
  }, 600);
  return () => clearTimeout(timer);
}, [values]);

We considered switching the whole form to an uncontrolled approach with a ref-based form library. The tradeoff: uncontrolled inputs do not re-render on value change, so they are faster per keystroke, but you lose the ability to show live conditional sections — "if sibling count > 2, show additional guardian fields" — without manual DOM manipulation. The form has 11 conditional sections. Managing those imperatively would have cost more than the re-renders we were eliminating.

The remaining problem was the address autocomplete. Each section's state is isolated, but the address section fetches suggestions from a PIN code API, and that fetch was triggering a full section re-render on every response. We memoized the suggestion list and used React's startTransition to mark the suggestion update as non-urgent. On the 3G connection, the PIN code lookup takes 800ms; without startTransition, the input froze for the duration of the request. With it, the user can keep typing while the suggestions populate.

The result: keystroke-to-paint latency dropped from roughly 400ms to under 60ms on the same device. The form still submits as a single payload — the submit handler collects each section's data via a ref-based registry — but the rendering work per keystroke is now proportional to the current section, not the whole form.

A 62-field form in one component is a reasonable starting point. It is not a reasonable ending point once you measure it on the phone your users actually carry.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch