Coreframe Labs
Field Guide/Frontend

Where AI coding assistants get frontends wrong

Generated UI code renders correctly on the first paint with the mock data. What it does not do is survive a slow network, a 50,000-row response, a keyboard, or a screen reader.

Coreframe Labs · · 6 patterns · 7 min read

The demo is the specification

Frontend code has an unusually forgiving feedback loop: it either looks right or it does not, and generated code almost always looks right. That is exactly what makes the failures hard to catch — there is no red test, no stack trace, and no error in the console. The component works.

It works with twelve rows of mock data on a fast laptop with a mouse. The production conditions — a real API returning thousands of rows, a user on a throttled connection double-clicking a button, someone navigating by keyboard, a screen reader announcing the page — are a different specification, and none of them were in the prompt.

These six are the ones we find most often, in roughly that order.

Pattern 01

Rendering the whole list

What we see

A table or feed that maps directly over the response array. With the mocked twenty rows it is instant. With the real response — a few thousand rows, each a component with its own subscriptions and event handlers — the main thread blocks for seconds on every render, and interaction latency goes with it.

Why an assistant produces it

array.map is the idiomatic React list, and it is correct. Virtualisation requires knowing the row count will be large, which the mock data actively contradicted.

The pattern

{rows.map((r) => <Row key={r.id} row={r} />)}

The fix

// Render the window, not the dataset.
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 44,
  overscan: 12,
});

{virtualizer.getVirtualItems().map((v) => (
  <Row key={rows[v.index].id} row={rows[v.index]} style={offsetFor(v)} />
))}

The correct implementation

Any list whose length is controlled by the server rather than by you needs either virtualisation or server-side pagination — decided when the component is written, because retrofitting it means rewriting the scroll container, the selection model, and any sticky headers.

How to test it

Point the component at a fixture with 10,000 rows and record a performance profile while scrolling. Look at long tasks and dropped frames, not at whether it eventually renders. Then repeat with CPU throttled 4x — that is closer to the median user's device than your laptop is.

Pattern 02

Stale closures in effects and callbacks

What we see

An effect that sets up an interval, subscription, or event listener and reads a piece of state inside the callback, with an empty dependency array. The callback closes over the first render's value forever. The symptom is a counter that stops advancing, a websocket handler that dispatches against stale data, or a debounced save that persists a value the user has already changed.

Why an assistant produces it

The empty dependency array is the shortest way to express "set this up once", which is the correct intent. That it also freezes every captured variable is a consequence of closure semantics, not of the intent — and the linter rule that catches it is frequently disabled because it is noisy.

The pattern

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []); // count is 0 in here, forever

The fix

useEffect(() => {
  // The updater form reads the current value; no capture, no staleness.
  const id = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(id);
}, []);

The correct implementation

Prefer the functional updater form for state that derives from itself. For handlers that must stay current without re-subscribing, keep the latest callback in a ref and invoke through it. And leave the exhaustive-deps lint rule on: every suppression should be a comment explaining why, not a blanket disable at the top of the file.

How to test it

Interact with the component before the async thing fires — change the input, then wait for the debounce; increment twice, then wait for the interval. Stale closures are invisible if you only test the component in its initial state, which is how generated tests test it.

Pattern 03

Async responses applied without cancellation

What we see

A fetch in an effect keyed on some ID, with the result written straight to state. Change the ID twice quickly — a search box, a tab switch, a route change — and two requests are now in flight. If the first resolves last, its response overwrites the second's. The UI shows data for the wrong entity, and there is nothing in the code to indicate it happened.

Why an assistant produces it

Fetch-then-setState is the canonical data-loading example, and it is correct for one request. The race only exists across a re-key, which single-render tests do not exercise.

The pattern

useEffect(() => {
  fetch(`/api/users/${id}`)
    .then((r) => r.json())
    .then(setUser);   // whichever finishes last wins
}, [id]);

The fix

useEffect(() => {
  const ac = new AbortController();
  fetch(`/api/users/${id}`, { signal: ac.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => ac.abort();   // supersede, don't race
}, [id]);

The correct implementation

Abort superseded requests in the effect's cleanup, and make sure the catch distinguishes an abort from a real error — swallowing both means a genuinely failed request renders as a permanent spinner. In practice, adopting a data-fetching library that handles request keying and deduplication removes the whole class, which is usually the better answer than fixing each instance.

How to test it

Throttle the network to Slow 3G and switch between two entities as fast as you can. Then, deterministically: mock the API so the first call resolves after a 2s delay and the second resolves immediately, and assert the rendered data matches the second.

Pattern 04

Effects used to derive state that could just be computed

What we see

A useState holding a filtered, sorted, or totalled version of some other state, kept in sync by a useEffect. Every change now costs two renders, and any code path that updates the source without going through the effect leaves the two out of sync — which is where "the total is wrong until you click something" bugs come from.

Why an assistant produces it

"When X changes, update Y" is a natural sentence, and useEffect is the hook that most literally means "when X changes". The framework guidance is to derive during render instead, but it is younger and rarer than the pattern it replaced.

The pattern

const [visible, setVisible] = useState([]);
useEffect(() => {
  setVisible(items.filter((i) => i.status === filter));
}, [items, filter]);

The fix

// One source of truth, computed during render.
const visible = useMemo(
  () => items.filter((i) => i.status === filter),
  [items, filter],
);

The correct implementation

If a value can be computed from props and state, compute it during render — with useMemo only when profiling says the computation is actually expensive. Reserve effects for synchronising with something outside React: the DOM, a subscription, a timer, an external store.

How to test it

Grep for useEffect bodies whose only statement is a setState. Almost every one is either derivable state or a missing event handler, and both are simpler after the change than before it.

Pattern 05

Interactive elements built from non-interactive ones

What we see

Buttons, tabs, dropdowns and modals implemented as divs with onClick. They work with a mouse. They are not reachable by keyboard, announce nothing to a screen reader, do not respond to Enter or Space, and — in the case of modals — do not trap focus, so tabbing walks the user into the page behind the overlay.

Why an assistant produces it

A div is the neutral element, and styling a native button to match a design system takes a reset the model has no reason to include. Nothing about the rendered output looks wrong, so nothing corrects it.

The pattern

<div className="btn" onClick={submit}>Save</div>

The fix

<button type="button" className="btn" onClick={submit}>
  Save
</button>
// If a div is genuinely unavoidable, all four are required:
// role="button" tabIndex={0} onKeyDown={enterOrSpace(submit)} aria-pressed=…

The correct implementation

Use the native element. It brings keyboard handling, focus management, the correct accessibility role, and form semantics for free — and every one of those has to be hand-written and hand-tested otherwise. For composite widgets that have no native equivalent (combobox, tabs, dialog), take them from a headless library that implements the ARIA pattern rather than assembling one.

How to test it

Put the mouse down and complete the primary flow with the keyboard alone. Tab, Enter, Space, Escape, arrow keys. Then run axe on the page — it catches the missing roles and labels mechanically, and it takes a minute. Neither of these is an accessibility audit, but together they catch most of what generated markup gets wrong.

Pattern 06

Layout shift from unreserved space

What we see

Images without width and height, content that renders after data arrives with no skeleton at the same dimensions, and banners or ad slots injected above existing content. Each one shoves the page down after first paint. The user taps the thing they were aiming at and hits whatever moved into its place.

Why an assistant produces it

Reserving space is invisible when the data is already there — and in development, with a local API and a warm cache, it always is. The shift only exists on a real network.

The pattern

{data ? <Chart data={data} /> : <Spinner />}
<img src={logo} alt="" />

The fix

{/* The placeholder occupies exactly the space the real thing will. */}
<div className="h-64">
  {data ? <Chart data={data} /> : <ChartSkeleton className="h-64" />}
</div>
<img src={logo} alt="" width={120} height={32} />

The correct implementation

Every element whose arrival is asynchronous needs its space reserved at the same dimensions before it arrives — intrinsic width and height on images and video, aspect-ratio or a fixed height on containers, and skeletons sized to match the content rather than to look tidy.

How to test it

Run Lighthouse with network throttling and read Cumulative Layout Shift; anything above 0.1 fails. Better, watch the page load once on Slow 3G with the cache disabled — the shifts are obvious to the eye and the recording tells you exactly which element caused each one.

What to review in AI-assisted frontend code

None of this is caught by looking at the component. It is caught by changing the conditions the component runs under — the data volume, the network, the input device.

  1. 01Re-run every list against a fixture two orders of magnitude larger than the mock data.
  2. 02Interact before async work settles: type, then wait; switch, then wait; click twice.
  3. 03Check every effect with an empty dependency array for captured state.
  4. 04Check every effect that only calls setState — it is probably derived state.
  5. 05Complete the primary flow with the keyboard alone, then run axe.
  6. 06Load the page once on a throttled connection with an empty cache and watch for shift.
  7. 07Confirm loading, empty, and error states exist at all — generated components frequently have only the success state.

Frontend code is where AI assistance is most productive and where its output is least scrutinised, because the feedback loop is visual and the visual is correct. That combination is why these patterns are so consistent.

The review question is not "does this render". It is "what are the conditions under which this was verified, and how far are they from production".

Preparing a codebase for real user data?

The Technical Audit runs this framework against your system: five business days, a written report separating passed controls from failed and deferred ones, a live findings call, and a costed remediation plan you own. Or start smaller — paste one file into the free lite audit and see how we think before you pay for it.