IBM Full Stack Software Developer study materials
IBM Full Stack Software Developer study resources

Input Latency and a Draft That Saves to the Wrong Claim

IBM Full Stack Software Developer · Put your knowledge to work in context.

All case studies

Input Latency and a Draft That Saves to the Wrong Claim

A React 18 internal claims dashboard renders a 38-field controlled form plus a SummaryPanel that recalculates totals. Interaction to Next Paint for typing averages 780 ms on a Moto G Power (2021) and about 90 ms on a developer's MacBook. Form state is one useState object in ClaimForm; every keystroke runs setForm({ ...form, [name]: value }). SummaryPanel is rendered as <SummaryPanel lineItems={form.lineItems} rates={buildRates()} />, where buildRates() reads a 400-entry rates table and returns a new object; SummaryPanel memoises its total with useMemo keyed on [lineItems, rates]. Elsewhere, a useEffect with an empty dependency array starts a 30-second setInterval that saves a draft; the callback reads form.id from its closure. QA reports that after opening a second claim, drafts keep saving against the first claim id. Twelve React Testing Library tests pass; none of them measure timing.

1. Where would you look first for the 780 ms lag, and how would you measure each change before shipping it?

Read the worked answer

Before profiling anything, confirm you are measuring a production build. React's development build does extra work per render, and StrictMode double-invokes render functions; a dev-build profile can be several times worse than what users get, which would send you optimising the wrong thing. Verify with react-dom/profiler data from a production bundle on the actual Moto G device.

Then let the Profiler rank components by committed render time rather than guessing. The strongest hypothesis is rates={buildRates()}: a fresh object identity on every keystroke means useMemo(..., [lineItems, rates]) never hits, so the 400-entry table is rebuilt and the total recomputed on every character typed. That is the classic memoisation defeated by an unstable prop. Second hypothesis is the 38 controlled inputs re-rendering from the single parent state object; that is usually cheap per input but adds up if any child does real work inline.

Fix in order of measured impact: hoist buildRates() to a module constant or a useMemo with no changing dependencies (the table is static); wrap SummaryPanel in React.memo so it only re-renders when lineItems changes; if typing is still slow, defer the summary with useDeferredValue so the keystroke stays urgent. Change one thing at a time and record INP plus a scripted keystroke-to-paint measurement on the throttled device, so you can attribute the gain rather than assuming it.

2. Explain the stale-closure bug and give a fix, including why simply adding `form` to the dependency array is a trap.

Read the worked answer

An interval callback closes over the variables visible in the render that created it. Because the effect has [] dependencies, it runs once, captures the form object from that first render, and stores form.id as a fixed value inside the callback. Navigating to a second claim updates state and re-renders, but it does not re-run the effect, so the interval keeps reading the id it captured at mount. Nothing about setInterval looks stale - it behaves exactly as written, which is why this is easy to miss in review.

Two fixes work. The ref approach: keep const idRef = useRef(form.id) and assign idRef.current = form.id on every render, then read idRef.current inside the interval. The interval is created once and always sees the current id. The dependency approach: useEffect(() => { const t = setInterval(...); return () => clearInterval(t); }, [form.id]). This recreates the timer whenever the id changes, which is fine because the id changes rarely.

The trap is listing form instead of form.id. Every keystroke produces a new form object, so the effect would tear down and restart the interval on each character. On a fast typist the 30-second timer never fires at all - timer starvation - and you have replaced a wrong-draft bug with a no-draft bug that is harder to notice. Also insist on the cleanup function: without it, dependency changes accumulate overlapping intervals and produce duplicate saves.

3. QA wants a regression test for the draft bug. What test would you write, and what are its limits?

Read the worked answer

Write a component-level test with fake timers that asserts on the request payload rather than on internal state.

Sketch: jest.useFakeTimers(); render ClaimForm with claim A; await act(() => jest.advanceTimersByTime(30_000)); assert the save function was called once with { id: 'A', ... }. Then rerender with claim B, advance another 30 seconds, and assert the call was made with { id: 'B' }. Finally assert no call after the navigation used id A, which is the actual regression assertion - asserting only that B appears would pass even if the old interval were still running alongside the new one.

Two limits to state openly. First, this test drives the claim change through props; the production bug is triggered by router navigation, and React Testing Library's jsdom environment does not model that, so the test must be structured so the id change is the thing under test, not the routing. Second, fake timers interact badly with real async work: the save call is a promise, so wrap advances in act and await pending microtasks, otherwise you get flaky assertions and warnings.

What the test does not give you is the performance regression. Add a separate, non-blocking measurement - a synthetic benchmark or a CI job running a Lighthouse/INP check on the built app - because 780 ms of input lag will never be caught by a correctness test.