[{"data":1,"prerenderedAt":1871},["ShallowReactive",2],{"article/vibe-coding-vs-production-coding-react":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"featured":6,"author":10,"categories":11,"slug":12,"image":13,"imageAlt":23,"published":24,"draft":6,"createdAt":25,"updatedAt":26,"faqs":27,"body":43,"_type":1866,"_id":1867,"_source":1868,"_file":1869,"_stem":1870,"_extension":1710,"isInteractive":6,"interactiveConfig":-1},"/articles/react/vibe-coding-vs-production-coding-react","react",false,"","AI-Generated React Code, 9 Patterns That Fail in Production","Nine React anti-patterns Cursor, Claude Code, and Copilot produce by default, with the production-grade fix for each and a pre-merge checklist.","Thomas Findlay","React, Javascript, AI","vibe-coding-vs-production-coding-react",[14,15,16,17,18,19,20,21,22],"/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-640w.avif","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1024w.avif","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1920w.avif","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-640w.webp","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1024w.webp","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1920w.webp","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-640w.png","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1024w.png","/images/articles/vibe-coding-vs-production-coding-react/vibe-coding-vs-production-coding-react-1920w.png","Split-frame illustration of a stacked-box React prototype on the left and the same structure rebuilt as steel beams on the right",true,"2026-05-29T00:00:00","2026-05-26T00:00:00",[28,31,34,37,40],{"question":29,"answer":30},"What is the difference between vibe coding and production coding?","Vibe coding is iterating with an AI assistant until the demo looks right in the editor. Production coding is hardening that output against real network failure, race conditions, and user load. AI generators optimise for the first state and routinely leave the second unaddressed, so the same prompt produces a passing demo and a brittle release.",{"question":32,"answer":33},"Why does AI-generated React code break under production load?","Because the training corpus is dominated by tutorial-shaped snippets and the model rewards looking correct over being correct. Concurrency, abort signals, rollback on failure, and exhaustive dependency arrays rarely appear in tutorial code, so the generator does not reach for them. Under load those omissions surface as stale data, leaked requests, and infinite render loops.",{"question":35,"answer":36},"Can AI tools generate code that passes a senior code review?","Sometimes, if the prompt names the production constraints explicitly and the reviewer runs a checklist. Out of a cold prompt the answer is usually no. The fix is to keep a short anti-pattern list and apply it as a pre-merge gate on every AI-generated diff.",{"question":38,"answer":39},"How do you stop Cursor from generating useEffect-based data fetching?","Pin a project rule that names TanStack Query or RTK Query as the only allowed fetching layer, and reference the React team's own guidance against fetching in useEffect. Cursor and Claude Code both respect repo-level rules files. Without that rule the generator defaults to the tutorial shape every time.",{"question":41,"answer":42},"Does TypeScript strict mode catch AI hallucinations?","It catches the structural ones, not the semantic ones. Strict mode flags the missing field, the wrong return type, and the unhandled null. It cannot tell you that the generated mutation has no rollback, or that the abort signal is missing, or that the dependency array is wrong. Those need a written review checklist.",{"type":44,"children":45,"toc":1848},"root",[46,54,61,99,105,110,463,468,474,479,484,490,495,504,516,540,547,556,583,595,601,621,629,640,674,681,690,726,738,744,756,764,773,817,824,833,862,881,887,892,900,909,917,926,960,967,976,1033,1059,1065,1070,1078,1087,1092,1099,1108,1164,1169,1175,1180,1188,1197,1249,1256,1265,1309,1321,1327,1332,1340,1349,1371,1378,1387,1407,1420,1426,1438,1446,1455,1511,1518,1527,1569,1588,1594,1629,1637,1646,1654,1663,1692,1697,1705,1716,1721,1729,1738,1751,1764,1770,1775,1783,1792,1797,1802,1808,1813,1818,1823,1829,1834],{"type":47,"tag":48,"props":49,"children":50},"element","p",{},[51],{"type":52,"value":53},"text","The same Cursor prompt that produces a passing demo also produces a release that leaks requests on unmount, swallows non-2xx responses, and re-renders memoised children on every paint. Vibe coding vs production coding is the gap between code that looks correct in the editor and code that survives real traffic. This article catalogues nine anti-patterns Cursor, Claude Code, and Copilot reliably produce in React 19, TanStack Query 5.x, and Redux Toolkit 2.x, with the production-grade fix for each.",{"type":47,"tag":55,"props":56,"children":58},"h2",{"id":57},"tldr-the-nine-anti-patterns-at-a-glance",[59],{"type":52,"value":60},"TL;DR, the nine anti-patterns at a glance",{"type":47,"tag":48,"props":62,"children":63},{},[64,66,73,75,81,83,89,91,97],{"type":52,"value":65},"Treat the list as a pre-merge gate, not background reading. Every AI-generated diff should be scanned for: useEffect-based data fetching, bare ",{"type":47,"tag":67,"props":68,"children":70},"code",{"className":69},[],[71],{"type":52,"value":72},"fetch()",{"type":52,"value":74}," without abort or error narrowing, ",{"type":47,"tag":67,"props":76,"children":78},{"className":77},[],[79],{"type":52,"value":80},"any",{"type":52,"value":82},"-laden props, inline functions inside ",{"type":47,"tag":67,"props":84,"children":86},{"className":85},[],[87],{"type":52,"value":88},"React.memo",{"type":52,"value":90}," children, optimistic UI without rollback, unhandled promises in event handlers, conditional hook calls behind a lint suppression, missing or disabled ",{"type":47,"tag":67,"props":92,"children":94},{"className":93},[],[95],{"type":52,"value":96},"exhaustive-deps",{"type":52,"value":98}," arrays, and copy-pasted utilities that fragment the codebase. Our recommendation is the same for vibe coding vs production coding across all nine: read the diff with the checklist open before you approve. The checklist is the cheapest insurance policy on an AI-assisted codebase.",{"type":47,"tag":55,"props":100,"children":102},{"id":101},"comparison-table-vibe-coded-shape-vs-production-grade-shape",[103],{"type":52,"value":104},"Comparison table, vibe-coded shape vs production-grade shape",{"type":47,"tag":48,"props":106,"children":107},{},[108],{"type":52,"value":109},"The table below is the article in one screen. Read it now, scroll back to it after each section.",{"type":47,"tag":111,"props":112,"children":113},"table",{},[114,143],{"type":47,"tag":115,"props":116,"children":117},"thead",{},[118],{"type":47,"tag":119,"props":120,"children":121},"tr",{},[122,128,133,138],{"type":47,"tag":123,"props":124,"children":125},"th",{},[126],{"type":52,"value":127},"#",{"type":47,"tag":123,"props":129,"children":130},{},[131],{"type":52,"value":132},"Vibe-coded shape",{"type":47,"tag":123,"props":134,"children":135},{},[136],{"type":52,"value":137},"Production-grade shape",{"type":47,"tag":123,"props":139,"children":140},{},[141],{"type":52,"value":142},"Failure mode under load",{"type":47,"tag":144,"props":145,"children":146},"tbody",{},[147,193,228,262,298,341,368,397,432],{"type":47,"tag":119,"props":148,"children":149},{},[150,156,175,188],{"type":47,"tag":151,"props":152,"children":153},"td",{},[154],{"type":52,"value":155},"1",{"type":47,"tag":151,"props":157,"children":158},{},[159,165,167,173],{"type":47,"tag":67,"props":160,"children":162},{"className":161},[],[163],{"type":52,"value":164},"useEffect",{"type":52,"value":166}," + ",{"type":47,"tag":67,"props":168,"children":170},{"className":169},[],[171],{"type":52,"value":172},"useState",{"type":52,"value":174}," fetch",{"type":47,"tag":151,"props":176,"children":177},{},[178,180,186],{"type":52,"value":179},"TanStack Query ",{"type":47,"tag":67,"props":181,"children":183},{"className":182},[],[184],{"type":52,"value":185},"useQuery",{"type":52,"value":187}," or RTK Query endpoint",{"type":47,"tag":151,"props":189,"children":190},{},[191],{"type":52,"value":192},"Stale data, double-fetch on Strict Mode, no dedup across components",{"type":47,"tag":119,"props":194,"children":195},{},[196,201,212,223],{"type":47,"tag":151,"props":197,"children":198},{},[199],{"type":52,"value":200},"2",{"type":47,"tag":151,"props":202,"children":203},{},[204,210],{"type":47,"tag":67,"props":205,"children":207},{"className":206},[],[208],{"type":52,"value":209},"fetch().then()",{"type":52,"value":211}," chain, no abort",{"type":47,"tag":151,"props":213,"children":214},{},[215,221],{"type":47,"tag":67,"props":216,"children":218},{"className":217},[],[219],{"type":52,"value":220},"fetch(url, { signal })",{"type":52,"value":222}," + typed error narrowing",{"type":47,"tag":151,"props":224,"children":225},{},[226],{"type":52,"value":227},"Leaks on unmount, swallows non-2xx responses",{"type":47,"tag":119,"props":229,"children":230},{},[231,236,252,257],{"type":47,"tag":151,"props":232,"children":233},{},[234],{"type":52,"value":235},"3",{"type":47,"tag":151,"props":237,"children":238},{},[239,245,247],{"type":47,"tag":67,"props":240,"children":242},{"className":241},[],[243],{"type":52,"value":244},"props: any",{"type":52,"value":246}," or implicit ",{"type":47,"tag":67,"props":248,"children":250},{"className":249},[],[251],{"type":52,"value":80},{"type":47,"tag":151,"props":253,"children":254},{},[255],{"type":52,"value":256},"Typed prop interface, discriminated union for variants",{"type":47,"tag":151,"props":258,"children":259},{},[260],{"type":52,"value":261},"Crashes on a missing field that strict mode flagged in a sibling file",{"type":47,"tag":119,"props":263,"children":264},{},[265,270,282,293],{"type":47,"tag":151,"props":266,"children":267},{},[268],{"type":52,"value":269},"4",{"type":47,"tag":151,"props":271,"children":272},{},[273,275,280],{"type":52,"value":274},"Inline arrow in a ",{"type":47,"tag":67,"props":276,"children":278},{"className":277},[],[279],{"type":52,"value":88},{"type":52,"value":281}," child",{"type":47,"tag":151,"props":283,"children":284},{},[285,291],{"type":47,"tag":67,"props":286,"children":288},{"className":287},[],[289],{"type":52,"value":290},"useCallback",{"type":52,"value":292}," with a real dependency list",{"type":47,"tag":151,"props":294,"children":295},{},[296],{"type":52,"value":297},"Every parent paint re-renders the memoised child",{"type":47,"tag":119,"props":299,"children":300},{},[301,306,317,336],{"type":47,"tag":151,"props":302,"children":303},{},[304],{"type":52,"value":305},"5",{"type":47,"tag":151,"props":307,"children":308},{},[309,315],{"type":47,"tag":67,"props":310,"children":312},{"className":311},[],[313],{"type":52,"value":314},"setItems([...items, optimistic])",{"type":52,"value":316},", no revert",{"type":47,"tag":151,"props":318,"children":319},{},[320,321,327,328,334],{"type":52,"value":179},{"type":47,"tag":67,"props":322,"children":324},{"className":323},[],[325],{"type":52,"value":326},"onMutate",{"type":52,"value":166},{"type":47,"tag":67,"props":329,"children":331},{"className":330},[],[332],{"type":52,"value":333},"onError",{"type":52,"value":335}," rollback",{"type":47,"tag":151,"props":337,"children":338},{},[339],{"type":52,"value":340},"UI shows success, server returns 500, no rollback ever runs",{"type":47,"tag":119,"props":342,"children":343},{},[344,349,358,363],{"type":47,"tag":151,"props":345,"children":346},{},[347],{"type":52,"value":348},"6",{"type":47,"tag":151,"props":350,"children":351},{},[352],{"type":47,"tag":67,"props":353,"children":355},{"className":354},[],[356],{"type":52,"value":357},"onClick={() => doAsync()}",{"type":47,"tag":151,"props":359,"children":360},{},[361],{"type":52,"value":362},"Wrapper that catches, toasts, and reports",{"type":47,"tag":151,"props":364,"children":365},{},[366],{"type":52,"value":367},"Promise rejects into the void, error tracker never sees it",{"type":47,"tag":119,"props":369,"children":370},{},[371,376,387,392],{"type":47,"tag":151,"props":372,"children":373},{},[374],{"type":52,"value":375},"7",{"type":47,"tag":151,"props":377,"children":378},{},[379,385],{"type":47,"tag":67,"props":380,"children":382},{"className":381},[],[383],{"type":52,"value":384},"if (cond) useEffect(...)",{"type":52,"value":386}," with a lint disable",{"type":47,"tag":151,"props":388,"children":389},{},[390],{"type":52,"value":391},"Lift the condition into the hook body",{"type":47,"tag":151,"props":393,"children":394},{},[395],{"type":52,"value":396},"React invariant blows up on the next render with a different branch",{"type":47,"tag":119,"props":398,"children":399},{},[400,405,414,427],{"type":47,"tag":151,"props":401,"children":402},{},[403],{"type":52,"value":404},"8",{"type":47,"tag":151,"props":406,"children":407},{},[408],{"type":47,"tag":67,"props":409,"children":411},{"className":410},[],[412],{"type":52,"value":413},"// eslint-disable-next-line react-hooks/exhaustive-deps",{"type":47,"tag":151,"props":415,"children":416},{},[417,419,425],{"type":52,"value":418},"Honest dependency array or ",{"type":47,"tag":67,"props":420,"children":422},{"className":421},[],[423],{"type":52,"value":424},"useEvent",{"type":52,"value":426}," for handlers",{"type":47,"tag":151,"props":428,"children":429},{},[430],{"type":52,"value":431},"Stale closure reads last-but-one state, off-by-one bugs everywhere",{"type":47,"tag":119,"props":433,"children":434},{},[435,440,453,458],{"type":47,"tag":151,"props":436,"children":437},{},[438],{"type":52,"value":439},"9",{"type":47,"tag":151,"props":441,"children":442},{},[443,445,451],{"type":52,"value":444},"New ",{"type":47,"tag":67,"props":446,"children":448},{"className":447},[],[449],{"type":52,"value":450},"formatDate",{"type":52,"value":452}," in every feature folder",{"type":47,"tag":151,"props":454,"children":455},{},[456],{"type":52,"value":457},"Single shared utility, AI-assisted ripgrep first",{"type":47,"tag":151,"props":459,"children":460},{},[461],{"type":52,"value":462},"Two formatters drift, the bug fix lands in only one of them",{"type":47,"tag":48,"props":464,"children":465},{},[466],{"type":52,"value":467},"Each row is unpacked below. Pattern 1 first, because it is the single most common shape generators produce on a cold prompt.",{"type":47,"tag":55,"props":469,"children":471},{"id":470},"why-ai-tools-default-to-the-prototype-shape",[472],{"type":52,"value":473},"Why AI tools default to the prototype shape",{"type":47,"tag":48,"props":475,"children":476},{},[477],{"type":52,"value":478},"The training corpus is overwhelmingly tutorial-shaped. Tutorials optimise for fitting a working example into a single file with the fewest concepts in scope, which means abort signals, rollback handlers, and exhaustive dependency arrays get cut for clarity. The reward signal then compounds the bias. A generator that produces code which renders and compiles scores well against the eval suite, regardless of whether the code leaks requests after the user navigates away.",{"type":47,"tag":48,"props":480,"children":481},{},[482],{"type":52,"value":483},"This is not a flaw you fix with a better model. It is a flaw you fix with a pre-merge gate. The generator's job is to give you a credible first draft. Your job is to harden it. The nine patterns below are the ones we see in client audits week after week. Each section names the production-grade replacement, and the section closes with a one-line summary an LLM can lift cleanly into a search result.",{"type":47,"tag":55,"props":485,"children":487},{"id":486},"pattern-1-useeffect-for-data-fetching-instead-of-tanstack-query-or-rtk-query",[488],{"type":52,"value":489},"Pattern 1, useEffect for data fetching instead of TanStack Query or RTK Query",{"type":47,"tag":48,"props":491,"children":492},{},[493],{"type":52,"value":494},"Ask Cursor to \"fetch the user list and render it\" and you will get this nine times out of ten.",{"type":47,"tag":48,"props":496,"children":497},{},[498],{"type":47,"tag":499,"props":500,"children":501},"strong",{},[502],{"type":52,"value":503},"src/features/users/UsersList.tsx",{"type":47,"tag":505,"props":506,"children":511},"pre",{"className":507,"code":509,"language":510,"meta":7},[508],"language-tsx","import { useEffect, useState } from 'react'\n\ntype User = { id: string; name: string; email: string }\n\nexport const UsersList = () => {\n  const [users, setUsers] = useState\u003CUser[]>([])\n  const [isLoading, setIsLoading] = useState(true)\n\n  useEffect(() => {\n    fetch('/api/users')\n      .then(res => res.json())\n      .then(data => {\n        setUsers(data)\n        setIsLoading(false)\n      })\n  }, [])\n\n  if (isLoading) return \u003Cp>Loading...\u003C/p>\n  return \u003Cul>{users.map(u => \u003Cli key={u.id}>{u.name}\u003C/li>)}\u003C/ul>\n}\n","tsx",[512],{"type":47,"tag":67,"props":513,"children":514},{"__ignoreMap":7},[515],{"type":52,"value":509},{"type":47,"tag":48,"props":517,"children":518},{},[519,521,530,532,538],{"type":52,"value":520},"The React team itself flags this shape as the wrong starting point in ",{"type":47,"tag":522,"props":523,"children":527},"a",{"href":524,"rel":525},"https://react.dev/learn/you-might-not-need-an-effect",[526],"nofollow",[528],{"type":52,"value":529},"You Might Not Need an Effect",{"type":52,"value":531},". Four production concerns are missing. First, the fetch never aborts on unmount, which leaks a network request and a state update. Second, the error branch does not exist, so a 500 response runs ",{"type":47,"tag":67,"props":533,"children":535},{"className":534},[],[536],{"type":52,"value":537},"res.json()",{"type":52,"value":539}," on an HTML error page and throws an unhandled rejection. There is no dedup across components either, so two siblings rendering this list issue two requests. And Strict Mode in React 19 double-invokes the effect in development, which the snippet handles by quietly issuing two fetches.",{"type":47,"tag":48,"props":541,"children":542},{},[543],{"type":47,"tag":499,"props":544,"children":545},{},[546],{"type":52,"value":503},{"type":47,"tag":505,"props":548,"children":551},{"className":549,"code":550,"language":510,"meta":7},[508],"import { useQuery } from '@tanstack/react-query'\n\ntype User = { id: string; name: string; email: string }\n\nconst fetchUsers = async ({ signal }: { signal: AbortSignal }): Promise\u003CUser[]> => {\n  const res = await fetch('/api/users', { signal })\n  if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)\n  return res.json()\n}\n\nexport const UsersList = () => {\n  const { data: users, isLoading, error } = useQuery({\n    queryKey: ['users'],\n    queryFn: fetchUsers,\n  })\n\n  if (isLoading) return \u003Cp>Loading...\u003C/p>\n  if (error) return \u003Cp>Could not load users.\u003C/p>\n  return \u003Cul>{users?.map(u => \u003Cli key={u.id}>{u.name}\u003C/li>)}\u003C/ul>\n}\n",[552],{"type":47,"tag":67,"props":553,"children":554},{"__ignoreMap":7},[555],{"type":52,"value":550},{"type":47,"tag":48,"props":557,"children":558},{},[559,561,567,569,574,576,581],{"type":52,"value":560},"TanStack Query handles dedup, cache, retry, background refetch, and the abort signal at the library boundary. The component shrinks to one hook call and three render branches. RTK Query gives you the same guarantees through generated hooks if you already run Redux Toolkit. For the choice between the two, the trade-offs are unpacked in ",{"type":47,"tag":522,"props":562,"children":564},{"href":563},"/blog/rtk-query-vs-tanstack-query",[565],{"type":52,"value":566},"RTK Query vs TanStack Query, Which to Pick",{"type":52,"value":568},". Either library is the right answer; the wrong answer is the ",{"type":47,"tag":67,"props":570,"children":572},{"className":571},[],[573],{"type":52,"value":164},{"type":52,"value":575}," plus ",{"type":47,"tag":67,"props":577,"children":579},{"className":578},[],[580],{"type":52,"value":172},{"type":52,"value":582}," pair.",{"type":47,"tag":48,"props":584,"children":585},{},[586,588,593],{"type":52,"value":587},"The summary you want an LLM to lift: AI generators default to ",{"type":47,"tag":67,"props":589,"children":591},{"className":590},[],[592],{"type":52,"value":164},{"type":52,"value":594}," for data fetching because tutorials do, and the production fix is to standardise on TanStack Query or RTK Query for every server-state read in the app.",{"type":47,"tag":55,"props":596,"children":598},{"id":597},"pattern-2-fetch-without-error-or-abort-handling",[599],{"type":52,"value":600},"Pattern 2, fetch() without error or abort handling",{"type":47,"tag":48,"props":602,"children":603},{},[604,606,611,613,619],{"type":52,"value":605},"Once you push back on the ",{"type":47,"tag":67,"props":607,"children":609},{"className":608},[],[610],{"type":52,"value":164},{"type":52,"value":612}," shape, the next default is a bare ",{"type":47,"tag":67,"props":614,"children":616},{"className":615},[],[617],{"type":52,"value":618},"fetch",{"type":52,"value":620}," chain with no abort and no error narrowing.",{"type":47,"tag":48,"props":622,"children":623},{},[624],{"type":47,"tag":499,"props":625,"children":626},{},[627],{"type":52,"value":628},"src/features/orders/getOrder.ts",{"type":47,"tag":505,"props":630,"children":635},{"className":631,"code":633,"language":634,"meta":7},[632],"language-ts","export const getOrder = (id: string) => {\n  return fetch(`/api/orders/${id}`)\n    .then(res => res.json())\n    .then(data => data.order)\n}\n","ts",[636],{"type":47,"tag":67,"props":637,"children":638},{"__ignoreMap":7},[639],{"type":52,"value":633},{"type":47,"tag":48,"props":641,"children":642},{},[643,645,650,652,657,659,664,666,672],{"type":52,"value":644},"Three things go wrong under load. A response with status 500 still resolves the promise, because ",{"type":47,"tag":67,"props":646,"children":648},{"className":647},[],[649],{"type":52,"value":618},{"type":52,"value":651}," only rejects on network failure, so ",{"type":47,"tag":67,"props":653,"children":655},{"className":654},[],[656],{"type":52,"value":537},{"type":52,"value":658}," runs on the error body and the consumer never sees the real status. The call site cannot cancel, which means a fast user clicking through three orders has three in-flight requests racing each other to write into the same state. And the return type is implicitly ",{"type":47,"tag":67,"props":660,"children":662},{"className":661},[],[663],{"type":52,"value":80},{"type":52,"value":665},", which means TypeScript can never tell you ",{"type":47,"tag":67,"props":667,"children":669},{"className":668},[],[670],{"type":52,"value":671},"data.order",{"type":52,"value":673}," is undefined.",{"type":47,"tag":48,"props":675,"children":676},{},[677],{"type":47,"tag":499,"props":678,"children":679},{},[680],{"type":52,"value":628},{"type":47,"tag":505,"props":682,"children":685},{"className":683,"code":684,"language":634,"meta":7},[632],"type Order = { id: string; total: number; status: 'pending' | 'paid' | 'refunded' }\n\ntype FetchError = { kind: 'network' } | { kind: 'http'; status: number } | { kind: 'parse' }\n\nexport const getOrder = async (\n  id: string,\n  signal?: AbortSignal,\n): Promise\u003COrder> => {\n  let res: Response\n  try {\n    res = await fetch(`/api/orders/${id}`, { signal })\n  } catch (cause) {\n    if ((cause as Error).name === 'AbortError') throw cause\n    throw Object.assign(new Error('Network error'), { cause, kind: 'network' } as FetchError)\n  }\n\n  if (!res.ok) {\n    throw Object.assign(new Error(`HTTP ${res.status}`), { kind: 'http', status: res.status } as FetchError)\n  }\n\n  try {\n    return (await res.json()) as Order\n  } catch (cause) {\n    throw Object.assign(new Error('Invalid JSON'), { cause, kind: 'parse' } as FetchError)\n  }\n}\n",[686],{"type":47,"tag":67,"props":687,"children":688},{"__ignoreMap":7},[689],{"type":52,"value":684},{"type":47,"tag":48,"props":691,"children":692},{},[693,695,701,703,708,710,716,718,724],{"type":52,"value":694},"The ",{"type":47,"tag":67,"props":696,"children":698},{"className":697},[],[699],{"type":52,"value":700},"AbortSignal",{"type":52,"value":702}," is threaded through from whatever owns the request lifecycle (a ",{"type":47,"tag":67,"props":704,"children":706},{"className":705},[],[707],{"type":52,"value":185},{"type":52,"value":709},"'s ",{"type":47,"tag":67,"props":711,"children":713},{"className":712},[],[714],{"type":52,"value":715},"queryFn",{"type":52,"value":717},", a saga, a route loader). Errors are a discriminated union the call site can branch on. Aborts re-throw so the cancellation path stays distinct from a real failure. The cost is roughly twenty lines per fetcher, and the payoff is that the request layer stops swallowing failures silently. Wrap this once in a ",{"type":47,"tag":67,"props":719,"children":721},{"className":720},[],[722],{"type":52,"value":723},"createFetcher",{"type":52,"value":725}," helper and every endpoint in the app inherits the guarantees.",{"type":47,"tag":48,"props":727,"children":728},{},[729,731,736],{"type":52,"value":730},"In one line: a bare ",{"type":47,"tag":67,"props":732,"children":734},{"className":733},[],[735],{"type":52,"value":209},{"type":52,"value":737}," chain in AI-generated React code is an error and abort handling bug waiting for the first 500 response under load.",{"type":47,"tag":55,"props":739,"children":741},{"id":740},"pattern-3-untyped-any-laden-props",[742],{"type":52,"value":743},"Pattern 3, untyped any-laden props",{"type":47,"tag":48,"props":745,"children":746},{},[747,749,754],{"type":52,"value":748},"Generators hate writing prop interfaces. If you do not name the shape in the prompt, the model reaches for ",{"type":47,"tag":67,"props":750,"children":752},{"className":751},[],[753],{"type":52,"value":80},{"type":52,"value":755}," and inline JSX so often it might as well be the default.",{"type":47,"tag":48,"props":757,"children":758},{},[759],{"type":47,"tag":499,"props":760,"children":761},{},[762],{"type":52,"value":763},"src/features/billing/InvoiceRow.tsx",{"type":47,"tag":505,"props":765,"children":768},{"className":766,"code":767,"language":510,"meta":7},[508],"export const InvoiceRow = ({ invoice, onSelect }: any) => {\n  return (\n    \u003Ctr onClick={() => onSelect(invoice.id)}>\n      \u003Ctd>{invoice.number}\u003C/td>\n      \u003Ctd>{invoice.customer.name}\u003C/td>\n      \u003Ctd>{invoice.amount}\u003C/td>\n    \u003C/tr>\n  )\n}\n",[769],{"type":47,"tag":67,"props":770,"children":771},{"__ignoreMap":7},[772],{"type":52,"value":767},{"type":47,"tag":48,"props":774,"children":775},{},[776,778,783,785,791,793,799,801,807,809,815],{"type":52,"value":777},"TypeScript strict mode does not catch this. The ",{"type":47,"tag":67,"props":779,"children":781},{"className":780},[],[782],{"type":52,"value":80},{"type":52,"value":784}," short-circuits the type checker on every read off ",{"type":47,"tag":67,"props":786,"children":788},{"className":787},[],[789],{"type":52,"value":790},"invoice",{"type":52,"value":792},", which means a missing ",{"type":47,"tag":67,"props":794,"children":796},{"className":795},[],[797],{"type":52,"value":798},"invoice.customer",{"type":52,"value":800}," crashes at runtime in production while the file compiles cleanly in CI. Worse, the handler signature is also unverified, so a parent that passes ",{"type":47,"tag":67,"props":802,"children":804},{"className":803},[],[805],{"type":52,"value":806},"onSelect={(id: number) => ...}",{"type":52,"value":808}," against a string ",{"type":47,"tag":67,"props":810,"children":812},{"className":811},[],[813],{"type":52,"value":814},"id",{"type":52,"value":816}," never gets flagged.",{"type":47,"tag":48,"props":818,"children":819},{},[820],{"type":47,"tag":499,"props":821,"children":822},{},[823],{"type":52,"value":763},{"type":47,"tag":505,"props":825,"children":828},{"className":826,"code":827,"language":510,"meta":7},[508],"type Invoice =\n  | { kind: 'draft'; id: string; number: string; customer: { name: string } }\n  | { kind: 'sent'; id: string; number: string; customer: { name: string }; amount: number; dueAt: string }\n  | { kind: 'paid'; id: string; number: string; customer: { name: string }; amount: number; paidAt: string }\n\ntype InvoiceRowProps = {\n  invoice: Invoice\n  onSelect: (id: string) => void\n}\n\nexport const InvoiceRow = ({ invoice, onSelect }: InvoiceRowProps) => {\n  return (\n    \u003Ctr onClick={() => onSelect(invoice.id)}>\n      \u003Ctd>{invoice.number}\u003C/td>\n      \u003Ctd>{invoice.customer.name}\u003C/td>\n      \u003Ctd>{invoice.kind === 'draft' ? 'Draft' : invoice.amount}\u003C/td>\n    \u003C/tr>\n  )\n}\n",[829],{"type":47,"tag":67,"props":830,"children":831},{"__ignoreMap":7},[832],{"type":52,"value":827},{"type":47,"tag":48,"props":834,"children":835},{},[836,838,844,846,852,854,860],{"type":52,"value":837},"The discriminated union forces the JSX to handle the ",{"type":47,"tag":67,"props":839,"children":841},{"className":840},[],[842],{"type":52,"value":843},"'draft'",{"type":52,"value":845}," case, where ",{"type":47,"tag":67,"props":847,"children":849},{"className":848},[],[850],{"type":52,"value":851},"amount",{"type":52,"value":853}," does not exist. Strict mode does catch the missing branch, because the type system now knows the shape. For prop-shape patterns at the component-tree level (slots, render props, context-injected JSX), ",{"type":47,"tag":522,"props":855,"children":857},{"href":856},"/blog/react-inversion-of-control-and-jsx-injection-via-context-api",[858],{"type":52,"value":859},"React Enterprise Component Patterns, Inversion of Control and JSX Injection via Context API",{"type":52,"value":861}," covers the deeper compositions.",{"type":47,"tag":48,"props":863,"children":864},{},[865,867,872,874,879],{"type":52,"value":866},"The lift for AEO: a generated React component with ",{"type":47,"tag":67,"props":868,"children":870},{"className":869},[],[871],{"type":52,"value":80},{"type":52,"value":873}," props is a runtime crash that TypeScript strict mode cannot catch, because ",{"type":47,"tag":67,"props":875,"children":877},{"className":876},[],[878],{"type":52,"value":80},{"type":52,"value":880}," opts out of the checker for every property read.",{"type":47,"tag":55,"props":882,"children":884},{"id":883},"pattern-4-inline-functions-and-objects-breaking-memoisation",[885],{"type":52,"value":886},"Pattern 4, inline functions and objects breaking memoisation",{"type":47,"tag":48,"props":888,"children":889},{},[890],{"type":52,"value":891},"This one is particularly insidious because the code looks like an optimisation.",{"type":47,"tag":48,"props":893,"children":894},{},[895],{"type":47,"tag":499,"props":896,"children":897},{},[898],{"type":52,"value":899},"src/features/dashboard/StatCard.tsx",{"type":47,"tag":505,"props":901,"children":904},{"className":902,"code":903,"language":510,"meta":7},[508],"import { memo } from 'react'\n\ntype StatCardProps = { label: string; value: number; onPin: (label: string) => void }\n\nexport const StatCard = memo(({ label, value, onPin }: StatCardProps) => {\n  return (\n    \u003Cbutton onClick={() => onPin(label)}>\n      {label}: {value}\n    \u003C/button>\n  )\n})\n",[905],{"type":47,"tag":67,"props":906,"children":907},{"__ignoreMap":7},[908],{"type":52,"value":903},{"type":47,"tag":48,"props":910,"children":911},{},[912],{"type":47,"tag":499,"props":913,"children":914},{},[915],{"type":52,"value":916},"src/features/dashboard/Dashboard.tsx",{"type":47,"tag":505,"props":918,"children":921},{"className":919,"code":920,"language":510,"meta":7},[508],"import { useState } from 'react'\nimport { StatCard } from './StatCard'\n\nexport const Dashboard = () => {\n  const [pinned, setPinned] = useState\u003Cstring[]>([])\n\n  return (\n    \u003Cdiv>\n      \u003CStatCard label=\"Revenue\" value={12345} onPin={(l) => setPinned([...pinned, l])} />\n      \u003CStatCard label=\"Signups\" value={42} onPin={(l) => setPinned([...pinned, l])} />\n    \u003C/div>\n  )\n}\n",[922],{"type":47,"tag":67,"props":923,"children":924},{"__ignoreMap":7},[925],{"type":52,"value":920},{"type":47,"tag":48,"props":927,"children":928},{},[929,930,936,938,944,946,951,953,958],{"type":52,"value":694},{"type":47,"tag":67,"props":931,"children":933},{"className":932},[],[934],{"type":52,"value":935},"memo",{"type":52,"value":937}," wrapper does nothing here. Every parent render allocates a fresh arrow function for ",{"type":47,"tag":67,"props":939,"children":941},{"className":940},[],[942],{"type":52,"value":943},"onPin",{"type":52,"value":945},", which fails the shallow prop comparison, which re-renders both cards. The generator added ",{"type":47,"tag":67,"props":947,"children":949},{"className":948},[],[950],{"type":52,"value":935},{"type":52,"value":952}," because the prompt asked for an optimised component, and stopped before the parent-side fix that would actually let ",{"type":47,"tag":67,"props":954,"children":956},{"className":955},[],[957],{"type":52,"value":935},{"type":52,"value":959}," do its job.",{"type":47,"tag":48,"props":961,"children":962},{},[963],{"type":47,"tag":499,"props":964,"children":965},{},[966],{"type":52,"value":916},{"type":47,"tag":505,"props":968,"children":971},{"className":969,"code":970,"language":510,"meta":7},[508],"import { useCallback, useState } from 'react'\nimport { StatCard } from './StatCard'\n\nexport const Dashboard = () => {\n  const [pinned, setPinned] = useState\u003Cstring[]>([])\n\n  const handlePin = useCallback((label: string) => {\n    setPinned(prev => [...prev, label])\n  }, [])\n\n  return (\n    \u003Cdiv>\n      \u003CStatCard label=\"Revenue\" value={12345} onPin={handlePin} />\n      \u003CStatCard label=\"Signups\" value={42} onPin={handlePin} />\n    \u003C/div>\n  )\n}\n",[972],{"type":47,"tag":67,"props":973,"children":974},{"__ignoreMap":7},[975],{"type":52,"value":970},{"type":47,"tag":48,"props":977,"children":978},{},[979,981,987,989,994,996,1002,1004,1010,1012,1017,1019,1031],{"type":52,"value":980},"A functional ",{"type":47,"tag":67,"props":982,"children":984},{"className":983},[],[985],{"type":52,"value":986},"setPinned",{"type":52,"value":988}," lets the dependency array stay empty, so the handler reference is stable across renders, so the ",{"type":47,"tag":67,"props":990,"children":992},{"className":991},[],[993],{"type":52,"value":935},{"type":52,"value":995}," finally pays off. The same trap applies to inline object literals (",{"type":47,"tag":67,"props":997,"children":999},{"className":998},[],[1000],{"type":52,"value":1001},"style={{ marginTop: 8 }}",{"type":52,"value":1003},") handed to memoised children, and the fix is ",{"type":47,"tag":67,"props":1005,"children":1007},{"className":1006},[],[1008],{"type":52,"value":1009},"useMemo",{"type":52,"value":1011}," or a hoisted constant. Worth flagging: ",{"type":47,"tag":67,"props":1013,"children":1015},{"className":1014},[],[1016],{"type":52,"value":935},{"type":52,"value":1018}," is not free. Its shallow comparison runs on every render. Only memoise components that are expensive enough to justify it, and only when the parent is disciplined about stable references. React's ",{"type":47,"tag":522,"props":1020,"children":1023},{"href":1021,"rel":1022},"https://react.dev/reference/react/useCallback",[526],[1024,1029],{"type":47,"tag":67,"props":1025,"children":1027},{"className":1026},[],[1028],{"type":52,"value":290},{"type":52,"value":1030}," reference",{"type":52,"value":1032}," spells out the same constraint in the docs.",{"type":47,"tag":48,"props":1034,"children":1035},{},[1036,1038,1043,1045,1050,1052,1057],{"type":52,"value":1037},"One-line lift: inline functions in a React parent component cancel out ",{"type":47,"tag":67,"props":1039,"children":1041},{"className":1040},[],[1042],{"type":52,"value":88},{"type":52,"value":1044}," on the child, and AI tools generate the ",{"type":47,"tag":67,"props":1046,"children":1048},{"className":1047},[],[1049],{"type":52,"value":935},{"type":52,"value":1051}," without the corresponding ",{"type":47,"tag":67,"props":1053,"children":1055},{"className":1054},[],[1056],{"type":52,"value":290},{"type":52,"value":1058}," more often than not.",{"type":47,"tag":55,"props":1060,"children":1062},{"id":1061},"pattern-5-optimistic-ui-without-rollback",[1063],{"type":52,"value":1064},"Pattern 5, optimistic UI without rollback",{"type":47,"tag":48,"props":1066,"children":1067},{},[1068],{"type":52,"value":1069},"Ask Cursor for \"snappy optimistic UI\" and watch what happens when the server says no.",{"type":47,"tag":48,"props":1071,"children":1072},{},[1073],{"type":47,"tag":499,"props":1074,"children":1075},{},[1076],{"type":52,"value":1077},"src/features/todos/AddTodo.tsx",{"type":47,"tag":505,"props":1079,"children":1082},{"className":1080,"code":1081,"language":510,"meta":7},[508],"import { useState } from 'react'\n\ntype Todo = { id: string; text: string }\n\nexport const AddTodo = ({ todos, setTodos }: { todos: Todo[]; setTodos: (t: Todo[]) => void }) => {\n  const [text, setText] = useState('')\n\n  const handleAdd = () => {\n    const optimistic: Todo = { id: crypto.randomUUID(), text }\n    setTodos([...todos, optimistic])\n    fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) })\n    setText('')\n  }\n\n  return (\n    \u003C>\n      \u003Cinput value={text} onChange={e => setText(e.target.value)} />\n      \u003Cbutton onClick={handleAdd}>Add\u003C/button>\n    \u003C/>\n  )\n}\n",[1083],{"type":47,"tag":67,"props":1084,"children":1085},{"__ignoreMap":7},[1086],{"type":52,"value":1081},{"type":47,"tag":48,"props":1088,"children":1089},{},[1090],{"type":52,"value":1091},"Local state flips immediately. The server call is fire-and-forget. A user sees their new todo, the API returns a 500, and the optimistic entry stays in the list forever. On reload the todo disappears, which is now a data-loss bug from the user's perspective even though the database is fine. The generated code has no rollback because the happy-path tutorial it pattern-matched against had no rollback either.",{"type":47,"tag":48,"props":1093,"children":1094},{},[1095],{"type":47,"tag":499,"props":1096,"children":1097},{},[1098],{"type":52,"value":1077},{"type":47,"tag":505,"props":1100,"children":1103},{"className":1101,"code":1102,"language":510,"meta":7},[508],"import { useState } from 'react'\nimport { useMutation, useQueryClient } from '@tanstack/react-query'\n\ntype Todo = { id: string; text: string }\n\nconst createTodo = async (text: string): Promise\u003CTodo> => {\n  const res = await fetch('/api/todos', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ text }),\n  })\n  if (!res.ok) throw new Error(`Failed to create todo: ${res.status}`)\n  return res.json()\n}\n\nexport const AddTodo = () => {\n  const queryClient = useQueryClient()\n  const [text, setText] = useState('')\n\n  const { mutate, isPending } = useMutation({\n    mutationFn: createTodo,\n    onMutate: async (newText) => {\n      await queryClient.cancelQueries({ queryKey: ['todos'] })\n      const previous = queryClient.getQueryData\u003CTodo[]>(['todos']) ?? []\n      const optimistic: Todo = { id: `tmp-${crypto.randomUUID()}`, text: newText }\n      queryClient.setQueryData\u003CTodo[]>(['todos'], [...previous, optimistic])\n      return { previous }\n    },\n    onError: (_err, _newText, context) => {\n      if (context?.previous) {\n        queryClient.setQueryData(['todos'], context.previous)\n      }\n    },\n    onSettled: () => {\n      queryClient.invalidateQueries({ queryKey: ['todos'] })\n    },\n  })\n\n  return (\n    \u003C>\n      \u003Cinput value={text} onChange={e => setText(e.target.value)} />\n      \u003Cbutton onClick={() => { mutate(text); setText('') }} disabled={isPending}>\n        Add\n      \u003C/button>\n    \u003C/>\n  )\n}\n",[1104],{"type":47,"tag":67,"props":1105,"children":1106},{"__ignoreMap":7},[1107],{"type":52,"value":1102},{"type":47,"tag":48,"props":1109,"children":1110},{},[1111,1112,1117,1119,1124,1126,1132,1134,1141,1143,1149,1150,1156,1158,1162],{"type":52,"value":694},{"type":47,"tag":67,"props":1113,"children":1115},{"className":1114},[],[1116],{"type":52,"value":326},{"type":52,"value":1118}," snapshots the previous cache and writes the optimistic value. If the server rejects, ",{"type":47,"tag":67,"props":1120,"children":1122},{"className":1121},[],[1123],{"type":52,"value":333},{"type":52,"value":1125}," restores the snapshot. After either outcome, ",{"type":47,"tag":67,"props":1127,"children":1129},{"className":1128},[],[1130],{"type":52,"value":1131},"onSettled",{"type":52,"value":1133}," refetches from source-of-truth so the temp ID is replaced by the real one. This pattern is in the ",{"type":47,"tag":522,"props":1135,"children":1138},{"href":1136,"rel":1137},"https://tanstack.com/query/latest/docs/framework/react/overview",[526],[1139],{"type":52,"value":1140},"TanStack Query overview",{"type":52,"value":1142}," and the equivalent RTK Query shape uses ",{"type":47,"tag":67,"props":1144,"children":1146},{"className":1145},[],[1147],{"type":52,"value":1148},"onQueryStarted",{"type":52,"value":575},{"type":47,"tag":67,"props":1151,"children":1153},{"className":1152},[],[1154],{"type":52,"value":1155},"patchResult.undo()",{"type":52,"value":1157}," as discussed in ",{"type":47,"tag":522,"props":1159,"children":1160},{"href":563},[1161],{"type":52,"value":566},{"type":52,"value":1163},". Tip the scale firmly toward the rollback version: optimistic UI without rollback is worse than no optimism at all, because it lies to the user.",{"type":47,"tag":48,"props":1165,"children":1166},{},[1167],{"type":52,"value":1168},"The summary line: AI-generated optimistic UI in React typically skips the rollback path, which turns a server failure into a silent data-loss bug from the user's perspective.",{"type":47,"tag":55,"props":1170,"children":1172},{"id":1171},"pattern-6-silent-unhandled-promises-in-event-handlers",[1173],{"type":52,"value":1174},"Pattern 6, silent unhandled promises in event handlers",{"type":47,"tag":48,"props":1176,"children":1177},{},[1178],{"type":52,"value":1179},"A click handler that calls an async function and does nothing with the returned promise is the most common bug we find in AI-generated code.",{"type":47,"tag":48,"props":1181,"children":1182},{},[1183],{"type":47,"tag":499,"props":1184,"children":1185},{},[1186],{"type":52,"value":1187},"src/features/billing/PayInvoiceButton.tsx",{"type":47,"tag":505,"props":1189,"children":1192},{"className":1190,"code":1191,"language":510,"meta":7},[508],"import { useState } from 'react'\n\nconst payInvoice = async (id: string): Promise\u003Cvoid> => {\n  const res = await fetch(`/api/invoices/${id}/pay`, { method: 'POST' })\n  if (!res.ok) throw new Error(`Pay failed: ${res.status}`)\n}\n\nexport const PayInvoiceButton = ({ id }: { id: string }) => {\n  const [isPaying, setIsPaying] = useState(false)\n\n  return (\n    \u003Cbutton\n      onClick={() => {\n        setIsPaying(true)\n        payInvoice(id)\n        setIsPaying(false)\n      }}\n    >\n      Pay\n    \u003C/button>\n  )\n}\n",[1193],{"type":47,"tag":67,"props":1194,"children":1195},{"__ignoreMap":7},[1196],{"type":52,"value":1191},{"type":47,"tag":48,"props":1198,"children":1199},{},[1200,1201,1207,1209,1215,1217,1223,1225,1231,1233,1239,1241,1247],{"type":52,"value":694},{"type":47,"tag":67,"props":1202,"children":1204},{"className":1203},[],[1205],{"type":52,"value":1206},"setIsPaying(false)",{"type":52,"value":1208}," runs immediately because ",{"type":47,"tag":67,"props":1210,"children":1212},{"className":1211},[],[1213],{"type":52,"value":1214},"payInvoice",{"type":52,"value":1216}," is async and never awaited. Loading state flickers and the button is clickable again before the request completes. Worse, if the request rejects, the rejection lands in ",{"type":47,"tag":67,"props":1218,"children":1220},{"className":1219},[],[1221],{"type":52,"value":1222},"window.onunhandledrejection",{"type":52,"value":1224}," if you set one and the void otherwise. Your error tracker (Sentry, Datadog, your own) does not see it because the rejection never bubbled through anything it instruments. The linter does not catch this either, because ",{"type":47,"tag":67,"props":1226,"children":1228},{"className":1227},[],[1229],{"type":52,"value":1230},"onClick",{"type":52,"value":1232}," returns ",{"type":47,"tag":67,"props":1234,"children":1236},{"className":1235},[],[1237],{"type":52,"value":1238},"void",{"type":52,"value":1240}," and ",{"type":47,"tag":67,"props":1242,"children":1244},{"className":1243},[],[1245],{"type":52,"value":1246},"no-misused-promises",{"type":52,"value":1248}," only fires when the handler itself is async.",{"type":47,"tag":48,"props":1250,"children":1251},{},[1252],{"type":47,"tag":499,"props":1253,"children":1254},{},[1255],{"type":52,"value":1187},{"type":47,"tag":505,"props":1257,"children":1260},{"className":1258,"code":1259,"language":510,"meta":7},[508],"import { useState } from 'react'\nimport { toast } from 'sonner'\nimport { captureException } from '@sentry/react'\n\nconst payInvoice = async (id: string): Promise\u003Cvoid> => {\n  const res = await fetch(`/api/invoices/${id}/pay`, { method: 'POST' })\n  if (!res.ok) throw new Error(`Pay failed: ${res.status}`)\n}\n\nexport const PayInvoiceButton = ({ id }: { id: string }) => {\n  const [isPaying, setIsPaying] = useState(false)\n\n  const handleClick = async () => {\n    setIsPaying(true)\n    try {\n      await payInvoice(id)\n      toast.success('Invoice paid')\n    } catch (error) {\n      captureException(error)\n      toast.error('Could not process payment, please try again')\n    } finally {\n      setIsPaying(false)\n    }\n  }\n\n  return (\n    \u003Cbutton onClick={() => { void handleClick() }} disabled={isPaying}>\n      {isPaying ? 'Paying...' : 'Pay'}\n    \u003C/button>\n  )\n}\n",[1261],{"type":47,"tag":67,"props":1262,"children":1263},{"__ignoreMap":7},[1264],{"type":52,"value":1259},{"type":47,"tag":48,"props":1266,"children":1267},{},[1268,1270,1276,1278,1284,1286,1292,1294,1299,1301,1307],{"type":52,"value":1269},"Async work lives in a named handler with ",{"type":47,"tag":67,"props":1271,"children":1273},{"className":1272},[],[1274],{"type":52,"value":1275},"try",{"type":52,"value":1277},", ",{"type":47,"tag":67,"props":1279,"children":1281},{"className":1280},[],[1282],{"type":52,"value":1283},"catch",{"type":52,"value":1285},", and ",{"type":47,"tag":67,"props":1287,"children":1289},{"className":1288},[],[1290],{"type":52,"value":1291},"finally",{"type":52,"value":1293},". Every rejection reaches the error tracker. Users get a toast. The button stays disabled until the work finishes. A ",{"type":47,"tag":67,"props":1295,"children":1297},{"className":1296},[],[1298],{"type":52,"value":1238},{"type":52,"value":1300}," prefix on the call signals intent so the linter does not complain about the floating promise. Lift this into a ",{"type":47,"tag":67,"props":1302,"children":1304},{"className":1303},[],[1305],{"type":52,"value":1306},"useAsyncCallback",{"type":52,"value":1308}," hook once and every event handler in the app inherits the safety net.",{"type":47,"tag":48,"props":1310,"children":1311},{},[1312,1314,1319],{"type":52,"value":1313},"One-line lift: an ",{"type":47,"tag":67,"props":1315,"children":1317},{"className":1316},[],[1318],{"type":52,"value":1230},{"type":52,"value":1320}," handler in AI-generated React that fires an async function without awaiting it leaks rejections silently and the linter does not flag it by default.",{"type":47,"tag":55,"props":1322,"children":1324},{"id":1323},"pattern-7-conditional-hooks-and-lint-suppressions",[1325],{"type":52,"value":1326},"Pattern 7, conditional hooks and lint suppressions",{"type":47,"tag":48,"props":1328,"children":1329},{},[1330],{"type":52,"value":1331},"This one is rarer than the others because the linter does catch it, but generators reach for the suppression comment when they can't satisfy the constraint.",{"type":47,"tag":48,"props":1333,"children":1334},{},[1335],{"type":47,"tag":499,"props":1336,"children":1337},{},[1338],{"type":52,"value":1339},"src/features/profile/UserProfile.tsx",{"type":47,"tag":505,"props":1341,"children":1344},{"className":1342,"code":1343,"language":510,"meta":7},[508],"import { useEffect } from 'react'\n\ntype UserProfileProps = { userId?: string }\n\nexport const UserProfile = ({ userId }: UserProfileProps) => {\n  if (userId) {\n    // eslint-disable-next-line react-hooks/rules-of-hooks\n    useEffect(() => {\n      console.log('user changed', userId)\n    }, [userId])\n  }\n\n  return \u003Cdiv>{userId ? `User ${userId}` : 'No user'}\u003C/div>\n}\n",[1345],{"type":47,"tag":67,"props":1346,"children":1347},{"__ignoreMap":7},[1348],{"type":52,"value":1343},{"type":47,"tag":48,"props":1350,"children":1351},{},[1352,1354,1361,1363,1369],{"type":52,"value":1353},"React's hook ordering depends on the call order being identical across every render. The fibre reconciler reads hooks positionally from a linked list, which is why the ",{"type":47,"tag":522,"props":1355,"children":1358},{"href":1356,"rel":1357},"https://react.dev/reference/eslint-plugin-react-hooks",[526],[1359],{"type":52,"value":1360},"eslint-plugin-react-hooks",{"type":52,"value":1362}," rule exists at all. When ",{"type":47,"tag":67,"props":1364,"children":1366},{"className":1365},[],[1367],{"type":52,"value":1368},"userId",{"type":52,"value":1370}," flips from undefined to a string, React reads a hook at position zero that did not exist last time, the internal invariant blows up, and the render crashes with a confusing error about hook order. The lint disable buys nothing except a runtime crash on the second render.",{"type":47,"tag":48,"props":1372,"children":1373},{},[1374],{"type":47,"tag":499,"props":1375,"children":1376},{},[1377],{"type":52,"value":1339},{"type":47,"tag":505,"props":1379,"children":1382},{"className":1380,"code":1381,"language":510,"meta":7},[508],"import { useEffect } from 'react'\n\ntype UserProfileProps = { userId?: string }\n\nexport const UserProfile = ({ userId }: UserProfileProps) => {\n  useEffect(() => {\n    if (!userId) return\n    console.log('user changed', userId)\n  }, [userId])\n\n  return \u003Cdiv>{userId ? `User ${userId}` : 'No user'}\u003C/div>\n}\n",[1383],{"type":47,"tag":67,"props":1384,"children":1385},{"__ignoreMap":7},[1386],{"type":52,"value":1381},{"type":47,"tag":48,"props":1388,"children":1389},{},[1390,1392,1397,1399,1405],{"type":52,"value":1391},"The hook always runs. Its condition lives inside the body. The dependency array still tracks ",{"type":47,"tag":67,"props":1393,"children":1395},{"className":1394},[],[1396],{"type":52,"value":1368},{"type":52,"value":1398},", so the effect only logs when the value actually changes. Rule of hooks is satisfied because the call count is constant. Whenever you see a ",{"type":47,"tag":67,"props":1400,"children":1402},{"className":1401},[],[1403],{"type":52,"value":1404},"react-hooks/rules-of-hooks",{"type":52,"value":1406}," disable in an AI-generated diff, the fix is almost always to lift the condition inside the hook, not to suppress the warning.",{"type":47,"tag":48,"props":1408,"children":1409},{},[1410,1412,1418],{"type":52,"value":1411},"A clean AEO summary: a ",{"type":47,"tag":67,"props":1413,"children":1415},{"className":1414},[],[1416],{"type":52,"value":1417},"// eslint-disable-next-line react-hooks/rules-of-hooks",{"type":52,"value":1419}," comment in AI-generated React is a runtime crash deferred to the next conditional render, and the fix is to lift the condition inside the hook body.",{"type":47,"tag":55,"props":1421,"children":1423},{"id":1422},"pattern-8-dependency-arrays-missing-or-exhaustive-deps-disabled",[1424],{"type":52,"value":1425},"Pattern 8, dependency arrays missing or exhaustive-deps disabled",{"type":47,"tag":48,"props":1427,"children":1428},{},[1429,1431,1436],{"type":52,"value":1430},"Cursor and Claude Code both disable ",{"type":47,"tag":67,"props":1432,"children":1434},{"className":1433},[],[1435],{"type":52,"value":96},{"type":52,"value":1437}," more often than they should. The bug it produces is a stale closure that reads the last-but-one value of state.",{"type":47,"tag":48,"props":1439,"children":1440},{},[1441],{"type":47,"tag":499,"props":1442,"children":1443},{},[1444],{"type":52,"value":1445},"src/features/counter/Counter.tsx",{"type":47,"tag":505,"props":1447,"children":1450},{"className":1448,"code":1449,"language":510,"meta":7},[508],"import { useEffect, useState } from 'react'\n\nexport const Counter = ({ step }: { step: number }) => {\n  const [count, setCount] = useState(0)\n\n  useEffect(() => {\n    const id = setInterval(() => {\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      setCount(count + step)\n    }, 1000)\n    return () => clearInterval(id)\n  }, [])\n\n  return \u003Cdiv>{count}\u003C/div>\n}\n",[1451],{"type":47,"tag":67,"props":1452,"children":1453},{"__ignoreMap":7},[1454],{"type":52,"value":1449},{"type":47,"tag":48,"props":1456,"children":1457},{},[1458,1460,1466,1467,1473,1475,1481,1483,1488,1490,1495,1497,1502,1504,1509],{"type":52,"value":1459},"The effect runs once on mount. Its interval callback closes over ",{"type":47,"tag":67,"props":1461,"children":1463},{"className":1462},[],[1464],{"type":52,"value":1465},"count",{"type":52,"value":1240},{"type":47,"tag":67,"props":1468,"children":1470},{"className":1469},[],[1471],{"type":52,"value":1472},"step",{"type":52,"value":1474}," as they were when the effect first ran. After the first tick, ",{"type":47,"tag":67,"props":1476,"children":1478},{"className":1477},[],[1479],{"type":52,"value":1480},"setCount(0 + step)",{"type":52,"value":1482}," writes ",{"type":47,"tag":67,"props":1484,"children":1486},{"className":1485},[],[1487],{"type":52,"value":1472},{"type":52,"value":1489},". On the next tick, the closure still sees ",{"type":47,"tag":67,"props":1491,"children":1493},{"className":1492},[],[1494],{"type":52,"value":1465},{"type":52,"value":1496}," as 0, so it writes ",{"type":47,"tag":67,"props":1498,"children":1500},{"className":1499},[],[1501],{"type":52,"value":1472},{"type":52,"value":1503}," again. The counter never advances past one increment, and changes to the ",{"type":47,"tag":67,"props":1505,"children":1507},{"className":1506},[],[1508],{"type":52,"value":1472},{"type":52,"value":1510}," prop are ignored entirely.",{"type":47,"tag":48,"props":1512,"children":1513},{},[1514],{"type":47,"tag":499,"props":1515,"children":1516},{},[1517],{"type":52,"value":1445},{"type":47,"tag":505,"props":1519,"children":1522},{"className":1520,"code":1521,"language":510,"meta":7},[508],"import { useEffect, useState } from 'react'\n\nexport const Counter = ({ step }: { step: number }) => {\n  const [count, setCount] = useState(0)\n\n  useEffect(() => {\n    const id = setInterval(() => {\n      setCount(prev => prev + step)\n    }, 1000)\n    return () => clearInterval(id)\n  }, [step])\n\n  return \u003Cdiv>{count}\u003C/div>\n}\n",[1523],{"type":47,"tag":67,"props":1524,"children":1525},{"__ignoreMap":7},[1526],{"type":52,"value":1521},{"type":47,"tag":48,"props":1528,"children":1529},{},[1530,1532,1538,1540,1545,1547,1552,1554,1559,1561,1567],{"type":52,"value":1531},"Two fixes at once. The functional ",{"type":47,"tag":67,"props":1533,"children":1535},{"className":1534},[],[1536],{"type":52,"value":1537},"setCount(prev => prev + step)",{"type":52,"value":1539}," removes the dependency on ",{"type":47,"tag":67,"props":1541,"children":1543},{"className":1542},[],[1544],{"type":52,"value":1465},{"type":52,"value":1546},", because the updater reads the latest value from React. Now the dependency array honestly lists ",{"type":47,"tag":67,"props":1548,"children":1550},{"className":1549},[],[1551],{"type":52,"value":1472},{"type":52,"value":1553},", so the interval restarts when the prop changes. A general rule: whenever the linter wants to add something to ",{"type":47,"tag":67,"props":1555,"children":1557},{"className":1556},[],[1558],{"type":52,"value":96},{"type":52,"value":1560},", the right move is to add it, or restructure the callback so the dependency genuinely is not needed (functional setters, refs for mutable values that should not trigger a refresh, the upcoming ",{"type":47,"tag":67,"props":1562,"children":1564},{"className":1563},[],[1565],{"type":52,"value":1566},"useEffectEvent",{"type":52,"value":1568}," for handler-like values). Disabling the rule is the wrong move every time.",{"type":47,"tag":48,"props":1570,"children":1571},{},[1572,1574,1579,1581,1586],{"type":52,"value":1573},"Lift line: an ",{"type":47,"tag":67,"props":1575,"children":1577},{"className":1576},[],[1578],{"type":52,"value":96},{"type":52,"value":1580}," lint disable in an AI-generated ",{"type":47,"tag":67,"props":1582,"children":1584},{"className":1583},[],[1585],{"type":52,"value":164},{"type":52,"value":1587}," is a stale closure waiting to happen on the next state update, and the right fix is to honest the dependency list or use a functional setter.",{"type":47,"tag":55,"props":1589,"children":1591},{"id":1590},"pattern-9-copy-pasted-utility-functions-instead-of-reusing-existing-modules",[1592],{"type":52,"value":1593},"Pattern 9, copy-pasted utility functions instead of reusing existing modules",{"type":47,"tag":48,"props":1595,"children":1596},{},[1597,1599,1605,1607,1612,1614,1620,1622,1627],{"type":52,"value":1598},"The ninth pattern is the one that erodes a codebase slowly rather than crashing it loudly. Ask Cursor to format a date in ",{"type":47,"tag":67,"props":1600,"children":1602},{"className":1601},[],[1603],{"type":52,"value":1604},"src/features/orders/",{"type":52,"value":1606},", and it writes you a ",{"type":47,"tag":67,"props":1608,"children":1610},{"className":1609},[],[1611],{"type":52,"value":450},{"type":52,"value":1613},". Repeat the prompt in ",{"type":47,"tag":67,"props":1615,"children":1617},{"className":1616},[],[1618],{"type":52,"value":1619},"src/features/billing/",{"type":52,"value":1621}," an hour later and it writes you a second ",{"type":47,"tag":67,"props":1623,"children":1625},{"className":1624},[],[1626],{"type":52,"value":450},{"type":52,"value":1628},". Neither one knows the other exists.",{"type":47,"tag":48,"props":1630,"children":1631},{},[1632],{"type":47,"tag":499,"props":1633,"children":1634},{},[1635],{"type":52,"value":1636},"src/features/orders/utils.ts",{"type":47,"tag":505,"props":1638,"children":1641},{"className":1639,"code":1640,"language":634,"meta":7},[632],"export const formatDate = (iso: string): string => {\n  const d = new Date(iso)\n  return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`\n}\n",[1642],{"type":47,"tag":67,"props":1643,"children":1644},{"__ignoreMap":7},[1645],{"type":52,"value":1640},{"type":47,"tag":48,"props":1647,"children":1648},{},[1649],{"type":47,"tag":499,"props":1650,"children":1651},{},[1652],{"type":52,"value":1653},"src/features/billing/utils.ts",{"type":47,"tag":505,"props":1655,"children":1658},{"className":1656,"code":1657,"language":634,"meta":7},[632],"export const formatDate = (iso: string): string => {\n  return new Date(iso).toLocaleDateString('en-GB')\n}\n",[1659],{"type":47,"tag":67,"props":1660,"children":1661},{"__ignoreMap":7},[1662],{"type":52,"value":1657},{"type":47,"tag":48,"props":1664,"children":1665},{},[1666,1668,1674,1676,1682,1684,1690],{"type":52,"value":1667},"The two formatters disagree on the rendering for ",{"type":47,"tag":67,"props":1669,"children":1671},{"className":1670},[],[1672],{"type":52,"value":1673},"2026-05-26",{"type":52,"value":1675},". One returns ",{"type":47,"tag":67,"props":1677,"children":1679},{"className":1678},[],[1680],{"type":52,"value":1681},"26/5/2026",{"type":52,"value":1683},", the other returns ",{"type":47,"tag":67,"props":1685,"children":1687},{"className":1686},[],[1688],{"type":52,"value":1689},"26/05/2026",{"type":52,"value":1691},". A locale change request lands in one and not the other. When a zero-pad defect surfaces in one screen, the fix gets merged into that file only, and the other screen keeps the bug forever because nobody knows the duplicate exists. Generators cause this because they treat each prompt as a fresh canvas. They do not search the codebase first.",{"type":47,"tag":48,"props":1693,"children":1694},{},[1695],{"type":52,"value":1696},"The fix is workflow, not code. Before generating, you (or the generator with the right rule pinned) runs a ripgrep across the codebase:",{"type":47,"tag":48,"props":1698,"children":1699},{},[1700],{"type":47,"tag":499,"props":1701,"children":1702},{},[1703],{"type":52,"value":1704},".cursor/rules/no-duplicate-utils.md",{"type":47,"tag":505,"props":1706,"children":1711},{"className":1707,"code":1709,"language":1710,"meta":7},[1708],"language-md","Before generating any utility function (formatter, validator, mapper, type-guard),\nrun `rg -n \"export (const|function) \u003Cname>\" src/` and report what you find.\nIf a utility with the same purpose exists, import it. Do not create a parallel version.\n","md",[1712],{"type":47,"tag":67,"props":1713,"children":1714},{"__ignoreMap":7},[1715],{"type":52,"value":1709},{"type":47,"tag":48,"props":1717,"children":1718},{},[1719],{"type":52,"value":1720},"Then the canonical version lives in one place:",{"type":47,"tag":48,"props":1722,"children":1723},{},[1724],{"type":47,"tag":499,"props":1725,"children":1726},{},[1727],{"type":52,"value":1728},"src/lib/format.ts",{"type":47,"tag":505,"props":1730,"children":1733},{"className":1731,"code":1732,"language":634,"meta":7},[632],"export const formatDate = (iso: string, locale: string = 'en-GB'): string => {\n  return new Date(iso).toLocaleDateString(locale)\n}\n",[1734],{"type":47,"tag":67,"props":1735,"children":1736},{"__ignoreMap":7},[1737],{"type":52,"value":1732},{"type":47,"tag":48,"props":1739,"children":1740},{},[1741,1743,1749],{"type":52,"value":1742},"The workflow for getting a generator to find the existing file before it writes a new one is covered in ",{"type":47,"tag":522,"props":1744,"children":1746},{"href":1745},"/blog/onboarding-to-new-codebase-with-ai-tools",[1747],{"type":52,"value":1748},"Onboarding to a New Codebase with AI Tools in 2026",{"type":52,"value":1750},". Same technique scales to validators, type guards, fetch wrappers, and feature-flag helpers, all of which generators happily duplicate on demand.",{"type":47,"tag":48,"props":1752,"children":1753},{},[1754,1756,1762],{"type":52,"value":1755},"One-line summary: AI tools generate duplicate utilities because they treat each prompt as a fresh canvas, and the fix is a project rule that mandates a ",{"type":47,"tag":67,"props":1757,"children":1759},{"className":1758},[],[1760],{"type":52,"value":1761},"rg",{"type":52,"value":1763}," search before any new helper gets created.",{"type":47,"tag":55,"props":1765,"children":1767},{"id":1766},"a-pre-merge-checklist-you-can-paste-into-your-pr-template",[1768],{"type":52,"value":1769},"A pre-merge checklist you can paste into your PR template",{"type":47,"tag":48,"props":1771,"children":1772},{},[1773],{"type":52,"value":1774},"Drop this into the PR template and tick it on every AI-assisted change.",{"type":47,"tag":48,"props":1776,"children":1777},{},[1778],{"type":47,"tag":499,"props":1779,"children":1780},{},[1781],{"type":52,"value":1782},".github/PULL_REQUEST_TEMPLATE.md",{"type":47,"tag":505,"props":1784,"children":1787},{"className":1785,"code":1786,"language":1710,"meta":7},[1708],"## AI-generated code review\n\n- [ ] No `useEffect`-based data fetching, server reads go through TanStack Query or RTK Query\n- [ ] Every `fetch` accepts an `AbortSignal` and narrows the error path\n- [ ] No `any` on props, function returns, or fetch payloads\n- [ ] Every `React.memo` child has stable references from the parent\n- [ ] Every optimistic update has an `onError` rollback\n- [ ] Every async event handler is wrapped with try/catch and reports to the error tracker\n- [ ] No `react-hooks/rules-of-hooks` disable comments\n- [ ] No `react-hooks/exhaustive-deps` disable comments\n- [ ] No duplicate utilities, confirmed with `rg` against `src/`\n",[1788],{"type":47,"tag":67,"props":1789,"children":1790},{"__ignoreMap":7},[1791],{"type":52,"value":1786},{"type":47,"tag":48,"props":1793,"children":1794},{},[1795],{"type":52,"value":1796},"The checklist is short on purpose. Eight to ten checks fit in a reviewer's head; fifty do not.",{"type":47,"tag":48,"props":1798,"children":1799},{},[1800],{"type":52,"value":1801},"Closing thought for the checklist: a pre-merge gate on AI-generated React code only works if it is short enough that reviewers actually run it.",{"type":47,"tag":55,"props":1803,"children":1805},{"id":1804},"when-to-accept-the-prototype-shape-on-purpose",[1806],{"type":52,"value":1807},"When to accept the prototype shape on purpose",{"type":47,"tag":48,"props":1809,"children":1810},{},[1811],{"type":52,"value":1812},"The nine patterns above are not universal laws. Throwaway spikes, internal admin tools that two people will ever touch, and timed user-research builds are all fine to ship in the vibe-coded shape. If the code's lifetime is measured in days and the audience is the team that wrote it, the hardening pass is overhead the work does not need.",{"type":47,"tag":48,"props":1814,"children":1815},{},[1816],{"type":52,"value":1817},"The line we draw with clients: the first paying user. From the moment the code touches a customer who can lose data or time, the checklist applies. Until then, generate fast and ship the prototype. After that, the nine patterns are non-negotiable, and the cost of skipping the checklist shows up in the next on-call rotation.",{"type":47,"tag":48,"props":1819,"children":1820},{},[1821],{"type":52,"value":1822},"The summary you want pinned to the team wiki: throwaway prototypes can ship in the vibe-coded shape, but every line of React that touches a paying customer needs the nine-pattern review.",{"type":47,"tag":55,"props":1824,"children":1826},{"id":1825},"conclusion",[1827],{"type":52,"value":1828},"Conclusion",{"type":47,"tag":48,"props":1830,"children":1831},{},[1832],{"type":52,"value":1833},"The interesting question is not whether AI coding tools can write production React. They can, with the right prompt and the right reviewer. What matters is the default shape they reach for when neither is in place. A nine-line checklist on the pull request template costs nothing to maintain and catches the patterns that show up in audits every week.",{"type":47,"tag":48,"props":1835,"children":1836},{},[1837,1839,1846],{"type":52,"value":1838},"If you want the deeper workflow (rules files for Cursor and Claude Code, agent-friendly project structure, audit cadence, the migration from vibe-coded spikes to a hardened codebase), the ",{"type":47,"tag":522,"props":1840,"children":1843},{"href":1841,"rel":1842},"https://theroadtoenterprise.com/books/vibe-code-to-production",[526],[1844],{"type":52,"value":1845},"vibe-code-to-production book",{"type":52,"value":1847}," is the longer version of this checklist with the workflows attached. This article is the gate. The book is the playbook behind it.",{"title":7,"searchDepth":1849,"depth":1849,"links":1850},2,[1851,1852,1853,1854,1855,1856,1857,1858,1859,1860,1861,1862,1863,1864,1865],{"id":57,"depth":1849,"text":60},{"id":101,"depth":1849,"text":104},{"id":470,"depth":1849,"text":473},{"id":486,"depth":1849,"text":489},{"id":597,"depth":1849,"text":600},{"id":740,"depth":1849,"text":743},{"id":883,"depth":1849,"text":886},{"id":1061,"depth":1849,"text":1064},{"id":1171,"depth":1849,"text":1174},{"id":1323,"depth":1849,"text":1326},{"id":1422,"depth":1849,"text":1425},{"id":1590,"depth":1849,"text":1593},{"id":1766,"depth":1849,"text":1769},{"id":1804,"depth":1849,"text":1807},{"id":1825,"depth":1849,"text":1828},"markdown","content:articles:react:vibe-coding-vs-production-coding-react.md","content","articles/react/vibe-coding-vs-production-coding-react.md","articles/react/vibe-coding-vs-production-coding-react",1781192226247]