[{"data":1,"prerenderedAt":1632},["ShallowReactive",2],{"article/hardening-ai-generated-react-app-for-production":3},{"_path":4,"_draft":5,"_partial":5,"_locale":6,"title":7,"description":8,"featured":5,"author":9,"categories":10,"slug":11,"image":12,"imageAlt":22,"published":23,"draft":5,"createdAt":24,"updatedAt":25,"faqs":26,"body":42,"_type":1631,"isInteractive":5,"interactiveConfig":-1},"/articles/react/hardening-ai-generated-react-app-for-production",false,"","Hardening an AI-Generated React App for Production","A six-dimension audit that turns an AI-generated React prototype into a shippable app, covering data, errors, types, performance, a11y, and observability.","Thomas Findlay","React, Javascript, AI, Typescript","hardening-ai-generated-react-app-for-production",[13,14,15,16,17,18,19,20,21],"/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-640w.avif","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1024w.avif","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1920w.avif","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-640w.webp","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1024w.webp","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1920w.webp","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-640w.png","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1024w.png","/images/articles/hardening-ai-generated-react-app-for-production/hardening-ai-generated-react-app-for-production-1920w.png","A bare React component tree clad with labelled armour plates for types, errors, queries, accessibility, performance, and observability.",true,"2026-06-09T00:00:00","2026-05-26T00:00:00",[27,30,33,36,39],{"question":28,"answer":29},"How do you know if AI-generated React code is production-ready?","Run a six-dimension pass: data layer, error handling, types, performance, accessibility, observability. If any dimension shows the AI defaulted to a demo-grade pattern, the app is not ready. The pass is the same checklist a senior reviewer would apply to a junior PR, only every file gets one.",{"question":31,"answer":32},"What is the fastest way to harden an AI-generated React prototype?","Move data fetching off raw useEffect and on to TanStack Query or RTK Query, then add route-level error boundaries. Those two changes catch the failure modes that crash a demo under real traffic. Types and observability come next.",{"question":34,"answer":35},"Should you rewrite or refactor AI-generated React code?","Refactor by dimension, not by file. The AI usually produced the right shape of component but the wrong data and error layers. Lift those layers out, keep the components, and rewrite only the seams that fail the audit.",{"question":37,"answer":38},"Which audit dimensions matter most for an AI-generated React app?","Data layer and error handling first, because they cause user-visible failures. Types and observability second, because they shorten every future debugging session. Performance and accessibility last, because they need real measurement and real users.",{"question":40,"answer":41},"What is the difference between a prototype and a production-ready React app?","A prototype answers the happy-path question. A production-ready app survives a failing API, a slow connection, an assistive technology, and an on-call engineer at 3am. The six-dimension pass is the bridge between the two.",{"type":43,"children":44,"toc":1602},"root",[45,53,60,106,111,146,152,188,193,202,214,219,225,286,293,306,311,319,330,359,364,372,383,396,402,415,423,432,440,449,454,460,480,486,499,507,516,521,526,534,543,564,570,580,588,597,626,632,653,659,672,679,690,747,752,760,769,788,794,808,815,824,852,858,872,878,883,891,900,936,944,953,989,995,1008,1016,1025,1054,1060,1088,1094,1099,1107,1116,1145,1157,1165,1174,1216,1222,1252,1258,1263,1271,1280,1309,1314,1322,1331,1336,1341,1349,1358,1369,1375,1380,1575,1580],{"type":46,"tag":47,"props":48,"children":49},"element","p",{},[50],{"type":51,"value":52},"text","AI-generated React code passes the demo and fails the on-call. The six-dimension audit pass, covering data, errors, types, performance, accessibility, and observability, is what turns a vibe-coded prototype into something you can ship. This article walks each dimension with the AI-generated starting point, the failure mode under real traffic, and the production-grade rewrite. Version targets: React 19.2, TanStack Query 5.100, Redux Toolkit 2.x, TypeScript 5.9.",{"type":46,"tag":54,"props":55,"children":57},"h2",{"id":56},"tldr-the-six-dimension-audit-pass",[58],{"type":51,"value":59},"TL;DR, the six-dimension audit pass",{"type":46,"tag":47,"props":61,"children":62},{},[63,65,72,74,80,82,88,90,96,98,104],{"type":51,"value":64},"Hardening AI-generated React code is a methodical pass across six dimensions where the model defaults to demo-grade patterns: replace raw ",{"type":46,"tag":66,"props":67,"children":69},"code",{"className":68},[],[70],{"type":51,"value":71},"useEffect",{"type":51,"value":73}," fetches with TanStack Query or RTK Query, wrap routes in error boundaries that log and recover, turn on strict TypeScript with ",{"type":46,"tag":66,"props":75,"children":77},{"className":76},[],[78],{"type":51,"value":79},"noUncheckedIndexedAccess",{"type":51,"value":81},", fix memoisation that the model either skipped or over-applied, restore focus management and semantic landmarks the AI omitted, and wire Sentry plus PostHog at the auth, payment, and top failure routes. The pass targets React 19.2, TanStack Query 5.100, Redux Toolkit 2.x, TypeScript 5.9, ",{"type":46,"tag":66,"props":83,"children":85},{"className":84},[],[86],{"type":51,"value":87},"@sentry/react",{"type":51,"value":89}," 10.x, and ",{"type":46,"tag":66,"props":91,"children":93},{"className":92},[],[94],{"type":51,"value":95},"posthog-js",{"type":51,"value":97}," 1.375+ with ",{"type":46,"tag":66,"props":99,"children":101},{"className":100},[],[102],{"type":51,"value":103},"@posthog/react",{"type":51,"value":105}," 1.9.x. Run it once per file before the prototype touches a real user.",{"type":46,"tag":47,"props":107,"children":108},{},[109],{"type":51,"value":110},"The six dimensions, one verb each:",{"type":46,"tag":112,"props":113,"children":114},"ol",{},[115,121,126,131,136,141],{"type":46,"tag":116,"props":117,"children":118},"li",{},[119],{"type":51,"value":120},"Data layer, replace.",{"type":46,"tag":116,"props":122,"children":123},{},[124],{"type":51,"value":125},"Error handling, contain.",{"type":46,"tag":116,"props":127,"children":128},{},[129],{"type":51,"value":130},"Type safety, tighten.",{"type":46,"tag":116,"props":132,"children":133},{},[134],{"type":51,"value":135},"Performance, measure.",{"type":46,"tag":116,"props":137,"children":138},{},[139],{"type":51,"value":140},"Accessibility, restore.",{"type":46,"tag":116,"props":142,"children":143},{},[144],{"type":51,"value":145},"Observability, instrument.",{"type":46,"tag":54,"props":147,"children":149},{"id":148},"the-starting-point-an-ai-generated-react-app",[150],{"type":51,"value":151},"The starting point, an AI-generated React app",{"type":46,"tag":47,"props":153,"children":154},{},[155,157,163,165,170,172,178,180,186],{"type":51,"value":156},"Most AI-generated React apps ship with three failure modes baked in: a raw ",{"type":46,"tag":66,"props":158,"children":160},{"className":159},[],[161],{"type":51,"value":162},"fetch",{"type":51,"value":164}," inside ",{"type":46,"tag":66,"props":166,"children":168},{"className":167},[],[169],{"type":51,"value":71},{"type":51,"value":171},", a ",{"type":46,"tag":66,"props":173,"children":175},{"className":174},[],[176],{"type":51,"value":177},"console.error",{"type":51,"value":179}," in the catch branch, and implicit ",{"type":46,"tag":66,"props":181,"children":183},{"className":182},[],[184],{"type":51,"value":185},"any",{"type":51,"value":187}," wherever the model could not infer a shape. The component renders fine on the happy path, the screenshots look right in the PR, and the app collapses the first time the API returns a 500.",{"type":46,"tag":47,"props":189,"children":190},{},[191],{"type":51,"value":192},"Here is the kind of dashboard widget the model produces when asked for \"a list of recent orders with a refresh button\". Notice how many of the six dimensions it already fails.",{"type":46,"tag":47,"props":194,"children":195},{},[196],{"type":46,"tag":197,"props":198,"children":199},"strong",{},[200],{"type":51,"value":201},"src/features/orders/RecentOrders.jsx",{"type":46,"tag":203,"props":204,"children":209},"pre",{"className":205,"code":207,"language":208,"meta":6},[206],"language-jsx","import { useEffect, useState } from 'react'\n\nexport default function RecentOrders() {\n  const [orders, setOrders] = useState([])\n  const [loading, setLoading] = useState(true)\n\n  useEffect(() => {\n    setLoading(true)\n    fetch('https://api.example.com/orders/recent')\n      .then(res => res.json())\n      .then(data => {\n        setOrders(data)\n        setLoading(false)\n      })\n      .catch(err => {\n        console.error(err)\n        setLoading(false)\n      })\n  }, [])\n\n  if (loading) return \u003Cdiv>Loading...\u003C/div>\n\n  return (\n    \u003Cdiv>\n      \u003Ch2>Recent orders\u003C/h2>\n      \u003Cul>\n        {orders.map(o => (\n          \u003Cli key={o.id}>\n            {o.customer} - {o.total}\n          \u003C/li>\n        ))}\n      \u003C/ul>\n    \u003C/div>\n  )\n}\n","jsx",[210],{"type":46,"tag":66,"props":211,"children":212},{"__ignoreMap":6},[213],{"type":51,"value":207},{"type":46,"tag":47,"props":215,"children":216},{},[217],{"type":51,"value":218},"That snippet is the reference point for the rest of the article. Every dimension fixes one specific thing this component does wrong. By the end, the code looks nothing like the original but the user-visible output is identical, which is the test of a good hardening pass.",{"type":46,"tag":54,"props":220,"children":222},{"id":221},"dimension-1-the-data-layer",[223],{"type":51,"value":224},"Dimension 1, the data layer",{"type":46,"tag":47,"props":226,"children":227},{},[228,230,235,236,241,243,248,250,259,261,268,270,276,278,284],{"type":51,"value":229},"The data layer is where AI-generated React collapses first. Models default to ",{"type":46,"tag":66,"props":231,"children":233},{"className":232},[],[234],{"type":51,"value":162},{"type":51,"value":164},{"type":46,"tag":66,"props":237,"children":239},{"className":238},[],[240],{"type":51,"value":71},{"type":51,"value":242}," because that pattern dominates the training set, and it brings four production failures with it: a double fetch under React 19 StrictMode, no retry on transient errors, no cache reuse, and a permanent loading flicker on navigation back. The fix is to stop using ",{"type":46,"tag":66,"props":244,"children":246},{"className":245},[],[247],{"type":51,"value":71},{"type":51,"value":249}," for data and delegate caching, retries, and request deduplication to a server-state library. For most teams in 2026 that is ",{"type":46,"tag":251,"props":252,"children":256},"a",{"href":253,"rel":254},"https://tanstack.com/query/latest",[255],"nofollow",[257],{"type":51,"value":258},"TanStack Query 5.x",{"type":51,"value":260},"; for teams already running Redux Toolkit, ",{"type":46,"tag":251,"props":262,"children":265},{"href":263,"rel":264},"https://redux-toolkit.js.org/rtk-query/overview",[255],[266],{"type":51,"value":267},"RTK Query 2.x",{"type":51,"value":269},". The trade-offs sit in ",{"type":46,"tag":251,"props":271,"children":273},{"href":272},"/blog/rtk-query-vs-tanstack-query",[274],{"type":51,"value":275},"RTK Query vs TanStack Query",{"type":51,"value":277},", and a deeper read on TanStack Query as an async state manager lives in ",{"type":46,"tag":251,"props":279,"children":281},{"href":280},"/blog/tanstack-query-is-not-just-for-api-requests",[282],{"type":51,"value":283},"this companion piece",{"type":51,"value":285},".",{"type":46,"tag":287,"props":288,"children":290},"h3",{"id":289},"replacing-useeffect-fetch-with-tanstack-query-or-rtk-query",[291],{"type":51,"value":292},"Replacing useEffect + fetch with TanStack Query or RTK Query",{"type":46,"tag":47,"props":294,"children":295},{},[296,298,304],{"type":51,"value":297},"The starting point above breaks the moment the user refocuses the tab. Nothing refetches, the data on screen is stale, and there is no signal to the user that the list has not been updated since the morning. Push the same component into a flaky network and it stays in ",{"type":46,"tag":66,"props":299,"children":301},{"className":300},[],[302],{"type":51,"value":303},"Loading...",{"type":51,"value":305}," forever, because the catch branch swallows the failure silently.",{"type":46,"tag":47,"props":307,"children":308},{},[309],{"type":51,"value":310},"The TanStack Query rewrite collapses both problems and shrinks the component.",{"type":46,"tag":47,"props":312,"children":313},{},[314],{"type":46,"tag":197,"props":315,"children":316},{},[317],{"type":51,"value":318},"src/features/orders/RecentOrders.tsx",{"type":46,"tag":203,"props":320,"children":325},{"className":321,"code":323,"language":324,"meta":6},[322],"language-tsx","import { useQuery } from '@tanstack/react-query'\nimport { ordersApi } from '@/api/orders'\nimport type { Order } from '@/api/orders'\n\nexport default function RecentOrders() {\n  const { data, isLoading, isError, error, refetch } = useQuery\u003COrder[], Error>({\n    queryKey: ['orders', 'recent'],\n    queryFn: ordersApi.recent,\n    staleTime: 30_000,\n    retry: 2,\n  })\n\n  if (isLoading) return \u003Cdiv role=\"status\">Loading recent orders\u003C/div>\n  if (isError) {\n    return (\n      \u003Cdiv role=\"alert\">\n        \u003Cp>Could not load recent orders. {error.message}\u003C/p>\n        \u003Cbutton type=\"button\" onClick={() => refetch()}>\n          Try again\n        \u003C/button>\n      \u003C/div>\n    )\n  }\n\n  return (\n    \u003Csection aria-labelledby=\"recent-orders-heading\">\n      \u003Ch2 id=\"recent-orders-heading\">Recent orders\u003C/h2>\n      \u003Cul>\n        {data?.map(o => (\n          \u003Cli key={o.id}>\n            {o.customer}, {o.total}\n          \u003C/li>\n        ))}\n      \u003C/ul>\n    \u003C/section>\n  )\n}\n","tsx",[326],{"type":46,"tag":66,"props":327,"children":328},{"__ignoreMap":6},[329],{"type":51,"value":323},{"type":46,"tag":47,"props":331,"children":332},{},[333,335,341,343,349,351,357],{"type":51,"value":334},"We get three behaviours for free that the original lacked. The query refetches when the tab regains focus, retries twice on transient failures, and shares its cache with any other component that asks for the same ",{"type":46,"tag":66,"props":336,"children":338},{"className":337},[],[339],{"type":51,"value":340},"queryKey",{"type":51,"value":342},". The component code is now purely about rendering states the user can see, and the failure mode is a recoverable UI with a retry button rather than a permanent spinner. Pair this with a single ",{"type":46,"tag":66,"props":344,"children":346},{"className":345},[],[347],{"type":51,"value":348},"QueryClientProvider",{"type":51,"value":350}," at the app root and ",{"type":46,"tag":66,"props":352,"children":354},{"className":353},[],[355],{"type":51,"value":356},"staleTime",{"type":51,"value":358}," tuned to the data's volatility and most \"the dashboard is stale\" tickets disappear.",{"type":46,"tag":47,"props":360,"children":361},{},[362],{"type":51,"value":363},"The same idea expressed in RTK Query if you are already invested in Redux Toolkit.",{"type":46,"tag":47,"props":365,"children":366},{},[367],{"type":46,"tag":197,"props":368,"children":369},{},[370],{"type":51,"value":371},"src/api/ordersApi.ts",{"type":46,"tag":203,"props":373,"children":378},{"className":374,"code":376,"language":377,"meta":6},[375],"language-ts","import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'\nimport type { Order } from '@/api/orders'\n\nexport const ordersApi = createApi({\n  reducerPath: 'ordersApi',\n  baseQuery: fetchBaseQuery({\n    baseUrl: import.meta.env.VITE_API_BASE_URL,\n  }),\n  tagTypes: ['Orders'],\n  endpoints: builder => ({\n    getRecentOrders: builder.query\u003COrder[], void>({\n      query: () => 'orders/recent',\n      providesTags: ['Orders'],\n    }),\n  }),\n})\n\nexport const { useGetRecentOrdersQuery } = ordersApi\n","ts",[379],{"type":46,"tag":66,"props":380,"children":381},{"__ignoreMap":6},[382],{"type":51,"value":376},{"type":46,"tag":47,"props":384,"children":385},{},[386,388,394],{"type":51,"value":387},"The difference between the two libraries is mostly bundle size, the shape of cache invalidation, and whether you already need Redux for client state. For teams who only need server state, TanStack Query is the lighter choice. For teams who already run Redux for cross-cutting client state, RTK Query rides on the store you already have. The client-state question itself is its own decision; see ",{"type":46,"tag":251,"props":389,"children":391},{"href":390},"/blog/zustand-vs-redux-toolkit",[392],{"type":51,"value":393},"Zustand vs Redux Toolkit",{"type":51,"value":395}," for the trade-off there.",{"type":46,"tag":287,"props":397,"children":399},{"id":398},"centralising-the-api-client-and-base-url",[400],{"type":51,"value":401},"Centralising the API client and base URL",{"type":46,"tag":47,"props":403,"children":404},{},[405,407,413],{"type":51,"value":406},"The AI-generated snippet hard-coded ",{"type":46,"tag":66,"props":408,"children":410},{"className":409},[],[411],{"type":51,"value":412},"https://api.example.com/orders/recent",{"type":51,"value":414},". That single decision blocks every environment promotion you will ever do. Staging cannot point at staging, the e2e tests cannot point at a local mock server, and the on-call engineer cannot flip the base URL when the production API moves behind a new edge proxy. Centralise the client and the base URL before any feature work continues.",{"type":46,"tag":47,"props":416,"children":417},{},[418],{"type":46,"tag":197,"props":419,"children":420},{},[421],{"type":51,"value":422},"src/api/client.ts",{"type":46,"tag":203,"props":424,"children":427},{"className":425,"code":426,"language":377,"meta":6},[375],"import axios, { AxiosError, AxiosInstance } from 'axios'\n\nconst baseURL = import.meta.env.VITE_API_BASE_URL\n\nif (!baseURL) {\n  throw new Error('VITE_API_BASE_URL is not set')\n}\n\nexport const apiClient: AxiosInstance = axios.create({\n  baseURL,\n  timeout: 10_000,\n  headers: { 'Content-Type': 'application/json' },\n})\n\napiClient.interceptors.response.use(\n  response => response,\n  (error: AxiosError) => {\n    if (error.response?.status === 401) {\n      window.dispatchEvent(new CustomEvent('auth:unauthorised'))\n    }\n    return Promise.reject(error)\n  },\n)\n",[428],{"type":46,"tag":66,"props":429,"children":430},{"__ignoreMap":6},[431],{"type":51,"value":426},{"type":46,"tag":47,"props":433,"children":434},{},[435],{"type":46,"tag":197,"props":436,"children":437},{},[438],{"type":51,"value":439},"src/api/orders.ts",{"type":46,"tag":203,"props":441,"children":444},{"className":442,"code":443,"language":377,"meta":6},[375],"import { apiClient } from '@/api/client'\n\nexport interface Order {\n  id: string\n  customer: string\n  total: string\n}\n\nexport const ordersApi = {\n  recent: async (): Promise\u003COrder[]> => {\n    const { data } = await apiClient.get\u003COrder[]>('/orders/recent')\n    return data\n  },\n}\n",[445],{"type":46,"tag":66,"props":446,"children":447},{"__ignoreMap":6},[448],{"type":51,"value":443},{"type":46,"tag":47,"props":450,"children":451},{},[452],{"type":51,"value":453},"The wrapper enforces three rules in one place: a missing base URL fails fast at boot rather than 500ms into the first render, every request has a sensible timeout so a hung backend does not freeze the UI, and a 401 emits a single event that the auth slice can subscribe to. Every new endpoint inherits all three for free. The wrapper is also the seam where Sentry breadcrumbs and request IDs attach later, which is why getting it in early pays off.",{"type":46,"tag":54,"props":455,"children":457},{"id":456},"dimension-2-error-handling-and-resilience",[458],{"type":51,"value":459},"Dimension 2, error handling and resilience",{"type":46,"tag":47,"props":461,"children":462},{},[463,465,471,473,478],{"type":51,"value":464},"The AI-generated React app handles errors the way the model saw most demo apps handle them: a ",{"type":46,"tag":66,"props":466,"children":468},{"className":467},[],[469],{"type":51,"value":470},"try/catch",{"type":51,"value":472}," that calls ",{"type":46,"tag":66,"props":474,"children":476},{"className":475},[],[477],{"type":51,"value":177},{"type":51,"value":479},", a flag that flips a generic spinner off, and nothing else. At 2am when the orders API returns 500, the user sees a blank screen, the on-call engineer sees no telemetry, and the front-end team gets a Slack message that reads \"the app is broken\". The fix is layered: error boundaries at the route and feature level, a single handler that fans an error out to the user, the log, and the reporter, and a deliberate choice about which errors recover automatically.",{"type":46,"tag":287,"props":481,"children":483},{"id":482},"error-boundaries-at-the-route-and-feature-level",[484],{"type":51,"value":485},"Error boundaries at the route and feature level",{"type":46,"tag":47,"props":487,"children":488},{},[489,491,497],{"type":51,"value":490},"React 19 still ships class-based error boundaries as the official primitive, and ",{"type":46,"tag":66,"props":492,"children":494},{"className":493},[],[495],{"type":51,"value":496},"react-error-boundary",{"type":51,"value":498}," is the production wrapper most teams reach for. Two layers matter: a route boundary that catches anything inside a page and shows a recovery UI, and a feature boundary that isolates a single widget so one failing chart does not blank the whole dashboard.",{"type":46,"tag":47,"props":500,"children":501},{},[502],{"type":46,"tag":197,"props":503,"children":504},{},[505],{"type":51,"value":506},"src/app/RouteErrorBoundary.tsx",{"type":46,"tag":203,"props":508,"children":511},{"className":509,"code":510,"language":324,"meta":6},[322],"import { ErrorBoundary, FallbackProps } from 'react-error-boundary'\nimport * as Sentry from '@sentry/react'\nimport { useNavigate } from 'react-router-dom'\n\nfunction RouteFallback({ error, resetErrorBoundary }: FallbackProps) {\n  const navigate = useNavigate()\n  return (\n    \u003Cdiv role=\"alert\" className=\"route-error\">\n      \u003Ch1>Something went wrong on this page\u003C/h1>\n      \u003Cp>{error.message}\u003C/p>\n      \u003Cbutton type=\"button\" onClick={resetErrorBoundary}>\n        Reload this page\n      \u003C/button>\n      \u003Cbutton type=\"button\" onClick={() => navigate('/')}>\n        Go home\n      \u003C/button>\n    \u003C/div>\n  )\n}\n\nexport function RouteErrorBoundary({ children }: { children: React.ReactNode }) {\n  return (\n    \u003CErrorBoundary\n      FallbackComponent={RouteFallback}\n      onError={(error, info) => {\n        Sentry.captureException(error, { extra: { componentStack: info.componentStack } })\n      }}\n    >\n      {children}\n    \u003C/ErrorBoundary>\n  )\n}\n",[512],{"type":46,"tag":66,"props":513,"children":514},{"__ignoreMap":6},[515],{"type":51,"value":510},{"type":46,"tag":47,"props":517,"children":518},{},[519],{"type":51,"value":520},"The route boundary does three jobs in one place. It reports the error with the React component stack attached, which makes the Sentry issue actually useful rather than a bare stack trace from the bundler. Users get a recoverable UI rather than a white screen, and a route out of the broken page so they are not stuck. Wrap every top-level route in this and you have eliminated the blank-screen failure mode in one PR.",{"type":46,"tag":47,"props":522,"children":523},{},[524],{"type":51,"value":525},"The feature-level boundary is the same component with a smaller, in-context fallback.",{"type":46,"tag":47,"props":527,"children":528},{},[529],{"type":46,"tag":197,"props":530,"children":531},{},[532],{"type":51,"value":533},"src/app/FeatureErrorBoundary.tsx",{"type":46,"tag":203,"props":535,"children":538},{"className":536,"code":537,"language":324,"meta":6},[322],"import { ErrorBoundary, FallbackProps } from 'react-error-boundary'\nimport * as Sentry from '@sentry/react'\n\nfunction FeatureFallback({ error, resetErrorBoundary }: FallbackProps) {\n  return (\n    \u003Cdiv role=\"alert\" className=\"feature-error\">\n      \u003Cp>This section is temporarily unavailable.\u003C/p>\n      \u003Cbutton type=\"button\" onClick={resetErrorBoundary}>\n        Retry\n      \u003C/button>\n    \u003C/div>\n  )\n}\n\nexport function FeatureErrorBoundary({\n  name,\n  children,\n}: {\n  name: string\n  children: React.ReactNode\n}) {\n  return (\n    \u003CErrorBoundary\n      FallbackComponent={FeatureFallback}\n      onError={error => {\n        Sentry.captureException(error, { tags: { feature: name } })\n      }}\n    >\n      {children}\n    \u003C/ErrorBoundary>\n  )\n}\n",[539],{"type":46,"tag":66,"props":540,"children":541},{"__ignoreMap":6},[542],{"type":51,"value":537},{"type":46,"tag":47,"props":544,"children":545},{},[546,548,554,556,562],{"type":51,"value":547},"The ",{"type":46,"tag":66,"props":549,"children":551},{"className":550},[],[552],{"type":51,"value":553},"name",{"type":51,"value":555}," tag is the payoff. When the recent orders widget throws, the Sentry issue carries ",{"type":46,"tag":66,"props":557,"children":559},{"className":558},[],[560],{"type":51,"value":561},"feature: recent-orders",{"type":51,"value":563}," and the on-call engineer can search for that one widget without combing through every error on the dashboard. Wrap each independent widget on a page in its own boundary and the blast radius of any single bug is reduced to a single card.",{"type":46,"tag":287,"props":565,"children":567},{"id":566},"toast-plus-log-plus-report-beyond-consoleerror",[568],{"type":51,"value":569},"Toast plus log plus report, beyond console.error",{"type":46,"tag":47,"props":571,"children":572},{},[573,578],{"type":46,"tag":66,"props":574,"children":576},{"className":575},[],[577],{"type":51,"value":177},{"type":51,"value":579}," is invisible to the user, invisible to the team, and invisible to the alerting stack. The three-channel rule is non-negotiable in production: every error notifies the user (toast or inline alert), writes a structured log (something a human can grep), and reports to the issue tracker (Sentry, PostHog, or equivalent). One handler should fan out to all three so the rule cannot be forgotten by accident.",{"type":46,"tag":47,"props":581,"children":582},{},[583],{"type":46,"tag":197,"props":584,"children":585},{},[586],{"type":51,"value":587},"src/lib/errorHandler.ts",{"type":46,"tag":203,"props":589,"children":592},{"className":590,"code":591,"language":377,"meta":6},[375],"import * as Sentry from '@sentry/react'\nimport { toast } from 'sonner'\n\ninterface ReportOptions {\n  userMessage?: string\n  context?: Record\u003Cstring, unknown>\n  silent?: boolean\n}\n\nexport function reportError(error: unknown, options: ReportOptions = {}): void {\n  const { userMessage, context, silent } = options\n  const err = error instanceof Error ? error : new Error(String(error))\n\n  if (!silent && userMessage) {\n    toast.error(userMessage)\n  }\n\n  console.error('[app-error]', err.message, context ?? {})\n\n  Sentry.captureException(err, {\n    extra: context,\n  })\n}\n",[593],{"type":46,"tag":66,"props":594,"children":595},{"__ignoreMap":6},[596],{"type":51,"value":591},{"type":46,"tag":47,"props":598,"children":599},{},[600,602,608,610,616,618,624],{"type":51,"value":601},"The handler accepts a ",{"type":46,"tag":66,"props":603,"children":605},{"className":604},[],[606],{"type":51,"value":607},"userMessage",{"type":51,"value":609}," because not every error needs a toast, and a ",{"type":46,"tag":66,"props":611,"children":613},{"className":612},[],[614],{"type":51,"value":615},"silent",{"type":51,"value":617}," flag for background sync failures that should be reported but not surfaced. The structured log prefix makes the entries trivially greppable in any aggregator. Calling code now looks like ",{"type":46,"tag":66,"props":619,"children":621},{"className":620},[],[622],{"type":51,"value":623},"reportError(err, { userMessage: 'Could not save changes', context: { orderId } })",{"type":51,"value":625}," instead of three lines copy-pasted into every catch block. The discipline is the saving; the consistency is what makes the alerting stack worth setting up.",{"type":46,"tag":54,"props":627,"children":629},{"id":628},"dimension-3-type-safety",[630],{"type":51,"value":631},"Dimension 3, type safety",{"type":46,"tag":47,"props":633,"children":634},{},[635,637,643,645,651],{"type":51,"value":636},"The AI-generated React app usually arrives with TypeScript turned on and ",{"type":46,"tag":66,"props":638,"children":640},{"className":639},[],[641],{"type":51,"value":642},"strict",{"type":51,"value":644}," turned off, which is the worst of both worlds: the build is slow, the IDE pretends to help, and nothing actually catches the bug where the API returns ",{"type":46,"tag":66,"props":646,"children":648},{"className":647},[],[649],{"type":51,"value":650},"null",{"type":51,"value":652}," for a field the component expected to be a string. Type safety in this audit is about two moves: turn the compiler all the way up, then stop hand-typing API responses and start inferring them from a schema.",{"type":46,"tag":287,"props":654,"children":656},{"id":655},"removing-implicit-any-from-generated-code",[657],{"type":51,"value":658},"Removing implicit any from generated code",{"type":46,"tag":47,"props":660,"children":661},{},[662,664,670],{"type":51,"value":663},"The first move is a ",{"type":46,"tag":66,"props":665,"children":667},{"className":666},[],[668],{"type":51,"value":669},"tsconfig.json",{"type":51,"value":671}," change.",{"type":46,"tag":47,"props":673,"children":674},{},[675],{"type":46,"tag":197,"props":676,"children":677},{},[678],{"type":51,"value":669},{"type":46,"tag":203,"props":680,"children":685},{"className":681,"code":683,"language":684,"meta":6},[682],"language-json","{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"noImplicitAny\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"exactOptionalPropertyTypes\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"isolatedModules\": true,\n    \"verbatimModuleSyntax\": true,\n    \"resolveJsonModule\": true,\n    \"baseUrl\": \".\",\n    \"paths\": { \"@/*\": [\"src/*\"] }\n  },\n  \"include\": [\"src\"]\n}\n","json",[686],{"type":46,"tag":66,"props":687,"children":688},{"__ignoreMap":6},[689],{"type":51,"value":683},{"type":46,"tag":47,"props":691,"children":692},{},[693,698,700,706,708,714,716,721,723,729,731,737,739,745],{"type":46,"tag":66,"props":694,"children":696},{"className":695},[],[697],{"type":51,"value":642},{"type":51,"value":699}," brings in ",{"type":46,"tag":66,"props":701,"children":703},{"className":702},[],[704],{"type":51,"value":705},"noImplicitAny",{"type":51,"value":707},", ",{"type":46,"tag":66,"props":709,"children":711},{"className":710},[],[712],{"type":51,"value":713},"strictNullChecks",{"type":51,"value":715},", and four other flags that the AI's generated code probably ignores. ",{"type":46,"tag":66,"props":717,"children":719},{"className":718},[],[720],{"type":51,"value":79},{"type":51,"value":722}," is the high-value extra: it forces you to handle the fact that ",{"type":46,"tag":66,"props":724,"children":726},{"className":725},[],[727],{"type":51,"value":728},"array[i]",{"type":51,"value":730}," might be undefined, which is the most common production bug in a list-heavy React app. ",{"type":46,"tag":66,"props":732,"children":734},{"className":733},[],[735],{"type":51,"value":736},"exactOptionalPropertyTypes",{"type":51,"value":738}," catches the difference between a prop being absent and a prop being ",{"type":46,"tag":66,"props":740,"children":742},{"className":741},[],[743],{"type":51,"value":744},"undefined",{"type":51,"value":746},", which matters as soon as you talk to a backend that distinguishes the two.",{"type":46,"tag":47,"props":748,"children":749},{},[750],{"type":51,"value":751},"The compiler will scream the first time. That is the point. The fixes are usually narrow.",{"type":46,"tag":47,"props":753,"children":754},{},[755],{"type":46,"tag":197,"props":756,"children":757},{},[758],{"type":51,"value":759},"src/features/orders/orderTotal.ts",{"type":46,"tag":203,"props":761,"children":764},{"className":762,"code":763,"language":377,"meta":6},[375],"import type { Order } from '@/api/orders'\n\nexport function formatCustomerName(order: Order | undefined): string {\n  if (!order) return 'Unknown customer'\n  return order.customer.trim() || 'Unnamed customer'\n}\n\nexport function firstOrderTotal(orders: Order[]): string {\n  const first = orders[0]\n  if (!first) return '0.00'\n  return first.total\n}\n",[765],{"type":46,"tag":66,"props":766,"children":767},{"__ignoreMap":6},[768],{"type":51,"value":763},{"type":46,"tag":47,"props":770,"children":771},{},[772,774,779,780,786],{"type":51,"value":773},"Without ",{"type":46,"tag":66,"props":775,"children":777},{"className":776},[],[778],{"type":51,"value":79},{"type":51,"value":707},{"type":46,"tag":66,"props":781,"children":783},{"className":782},[],[784],{"type":51,"value":785},"orders[0].total",{"type":51,"value":787}," typechecks and crashes at runtime on an empty list. With the flag on, the compiler insists on the guard and the crash never happens. The cost is a few lines of guard code per indexed access; the benefit is the entire class of \"cannot read properties of undefined\" errors disappearing from the bug tracker.",{"type":46,"tag":287,"props":789,"children":791},{"id":790},"inferring-api-types-from-a-schema-not-from-the-response",[792],{"type":51,"value":793},"Inferring API types from a schema, not from the response",{"type":46,"tag":47,"props":795,"children":796},{},[797,799,806],{"type":51,"value":798},"Hand-typed API response interfaces are a fiction. The compiler trusts the interface, the runtime trusts the network, and the two diverge the first time the backend renames a field. A schema-first approach with ",{"type":46,"tag":251,"props":800,"children":803},{"href":801,"rel":802},"https://zod.dev",[255],[804],{"type":51,"value":805},"Zod",{"type":51,"value":807}," closes the loop: define the shape once, validate at the boundary, infer the TypeScript type from the same definition.",{"type":46,"tag":47,"props":809,"children":810},{},[811],{"type":46,"tag":197,"props":812,"children":813},{},[814],{"type":51,"value":439},{"type":46,"tag":203,"props":816,"children":819},{"className":817,"code":818,"language":377,"meta":6},[375],"import { z } from 'zod'\nimport { apiClient } from '@/api/client'\n\nexport const OrderSchema = z.object({\n  id: z.string(),\n  customer: z.string(),\n  total: z.string(),\n  createdAt: z.string().datetime(),\n})\n\nexport const OrdersResponseSchema = z.array(OrderSchema)\n\nexport type Order = z.infer\u003Ctypeof OrderSchema>\n\nexport const ordersApi = {\n  recent: async (): Promise\u003COrder[]> => {\n    const { data } = await apiClient.get('/orders/recent')\n    return OrdersResponseSchema.parse(data)\n  },\n}\n",[820],{"type":46,"tag":66,"props":821,"children":822},{"__ignoreMap":6},[823],{"type":51,"value":818},{"type":46,"tag":47,"props":825,"children":826},{},[827,829,835,837,843,845,850],{"type":51,"value":828},"Two things changed. The ",{"type":46,"tag":66,"props":830,"children":832},{"className":831},[],[833],{"type":51,"value":834},"Order",{"type":51,"value":836}," type is now derived from ",{"type":46,"tag":66,"props":838,"children":840},{"className":839},[],[841],{"type":51,"value":842},"OrderSchema",{"type":51,"value":844},", so the type and the runtime check cannot drift apart. Responses are validated at the network boundary, so a backend that drops a required field fails loudly at the seam rather than silently rendering ",{"type":46,"tag":66,"props":846,"children":848},{"className":847},[],[849],{"type":51,"value":744},{"type":51,"value":851}," two layers deeper in the component tree. The cost is the validation pass on every response, which is measured in microseconds for a typical payload. Worth every one of them.",{"type":46,"tag":54,"props":853,"children":855},{"id":854},"dimension-4-performance",[856],{"type":51,"value":857},"Dimension 4, performance",{"type":46,"tag":47,"props":859,"children":860},{},[861,863,870],{"type":51,"value":862},"The AI gets performance backwards. It memoises components that render once and forgets to stabilise the callback that triggers a hundred re-renders elsewhere. The fix is to measure with the React DevTools profiler, find the actual hot paths, and apply memoisation only where it earns its complexity. The official ",{"type":46,"tag":251,"props":864,"children":867},{"href":865,"rel":866},"https://react.dev/reference/react/useMemo",[255],[868],{"type":51,"value":869},"React useMemo docs",{"type":51,"value":871}," carry the framework team's own warning that the hook is an optimisation and not a default.",{"type":46,"tag":287,"props":873,"children":875},{"id":874},"memoisation-and-stable-references-the-ai-missed",[876],{"type":51,"value":877},"Memoisation and stable references the AI missed",{"type":46,"tag":47,"props":879,"children":880},{},[881],{"type":51,"value":882},"A typical AI-generated component looks like this, with memoisation in the wrong places.",{"type":46,"tag":47,"props":884,"children":885},{},[886],{"type":46,"tag":197,"props":887,"children":888},{},[889],{"type":51,"value":890},"src/features/orders/OrdersList.tsx (before)",{"type":46,"tag":203,"props":892,"children":895},{"className":893,"code":894,"language":324,"meta":6},[322],"import { useMemo } from 'react'\nimport type { Order } from '@/api/orders'\n\ninterface Props {\n  orders: Order[]\n  onSelect: (id: string) => void\n}\n\nexport function OrdersList({ orders, onSelect }: Props) {\n  const heading = useMemo(() => 'Recent orders', [])\n  const sortedOrders = useMemo(\n    () => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),\n    [orders],\n  )\n\n  return (\n    \u003Csection>\n      \u003Ch2>{heading}\u003C/h2>\n      \u003Cul>\n        {sortedOrders.map(o => (\n          \u003Cli key={o.id} onClick={() => onSelect(o.id)}>\n            {o.customer}\n          \u003C/li>\n        ))}\n      \u003C/ul>\n    \u003C/section>\n  )\n}\n",[896],{"type":46,"tag":66,"props":897,"children":898},{"__ignoreMap":6},[899],{"type":51,"value":894},{"type":46,"tag":47,"props":901,"children":902},{},[903,904,910,912,918,920,926,928,934],{"type":51,"value":547},{"type":46,"tag":66,"props":905,"children":907},{"className":906},[],[908],{"type":51,"value":909},"heading",{"type":51,"value":911}," memoisation is pure waste; it caches a string literal that the JavaScript engine deduplicates anyway. Sorting inside ",{"type":46,"tag":66,"props":913,"children":915},{"className":914},[],[916],{"type":51,"value":917},"sortedOrders",{"type":51,"value":919}," is the memo that earns its keep, because the comparison is O(n log n) on every render. Look at the inline arrow on ",{"type":46,"tag":66,"props":921,"children":923},{"className":922},[],[924],{"type":51,"value":925},"onClick",{"type":51,"value":927},", though, and you find the actual performance bug: every list item gets a fresh function on every parent render, which breaks any ",{"type":46,"tag":66,"props":929,"children":931},{"className":930},[],[932],{"type":51,"value":933},"React.memo",{"type":51,"value":935}," wrapping the list items might have. The fix is to drop the useless memo, keep the useful one, and stabilise the click handler.",{"type":46,"tag":47,"props":937,"children":938},{},[939],{"type":46,"tag":197,"props":940,"children":941},{},[942],{"type":51,"value":943},"src/features/orders/OrdersList.tsx (after)",{"type":46,"tag":203,"props":945,"children":948},{"className":946,"code":947,"language":324,"meta":6},[322],"import { useCallback, useMemo } from 'react'\nimport type { Order } from '@/api/orders'\n\ninterface Props {\n  orders: Order[]\n  onSelect: (id: string) => void\n}\n\ninterface RowProps {\n  order: Order\n  onSelect: (id: string) => void\n}\n\nconst OrderRow = ({ order, onSelect }: RowProps) => {\n  const handleClick = useCallback(() => onSelect(order.id), [order.id, onSelect])\n  return (\n    \u003Cli>\n      \u003Cbutton type=\"button\" onClick={handleClick}>\n        {order.customer}\n      \u003C/button>\n    \u003C/li>\n  )\n}\n\nexport function OrdersList({ orders, onSelect }: Props) {\n  const sortedOrders = useMemo(\n    () => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),\n    [orders],\n  )\n\n  return (\n    \u003Csection>\n      \u003Ch2>Recent orders\u003C/h2>\n      \u003Cul>\n        {sortedOrders.map(order => (\n          \u003COrderRow key={order.id} order={order} onSelect={onSelect} />\n        ))}\n      \u003C/ul>\n    \u003C/section>\n  )\n}\n",[949],{"type":46,"tag":66,"props":950,"children":951},{"__ignoreMap":6},[952],{"type":51,"value":947},{"type":46,"tag":47,"props":954,"children":955},{},[956,958,964,966,971,973,979,981,987],{"type":51,"value":957},"The trivial memo is gone, the expensive sort is still cached, and the row is its own component so the click handler stays stable per row. ",{"type":46,"tag":66,"props":959,"children":961},{"className":960},[],[962],{"type":51,"value":963},"useCallback",{"type":51,"value":965}," here is not a magic wand for performance; it is a stable-reference tool that lets ",{"type":46,"tag":66,"props":967,"children":969},{"className":968},[],[970],{"type":51,"value":933},{"type":51,"value":972}," actually do its job. The ",{"type":46,"tag":66,"props":974,"children":976},{"className":975},[],[977],{"type":51,"value":978},"\u003Cbutton>",{"type":51,"value":980}," element also gets us keyboard accessibility for free, which the original ",{"type":46,"tag":66,"props":982,"children":984},{"className":983},[],[985],{"type":51,"value":986},"\u003Cli onClick>",{"type":51,"value":988}," did not have. React 19's new compiler can automate some of these decisions when you opt in, but it does not exempt you from understanding the trade-off; it only changes who writes the boilerplate.",{"type":46,"tag":287,"props":990,"children":992},{"id":991},"bundle-size-and-route-level-code-splitting",[993],{"type":51,"value":994},"Bundle size and route-level code splitting",{"type":46,"tag":47,"props":996,"children":997},{},[998,1000,1006],{"type":51,"value":999},"The AI rarely splits the bundle. Every route ships in the initial JavaScript payload, the time-to-interactive metric is whatever the network gives you, and the first paint includes code paths the user has not asked to see. Route-level ",{"type":46,"tag":66,"props":1001,"children":1003},{"className":1002},[],[1004],{"type":51,"value":1005},"React.lazy",{"type":51,"value":1007}," is the single highest-impact performance fix in a React SPA.",{"type":46,"tag":47,"props":1009,"children":1010},{},[1011],{"type":46,"tag":197,"props":1012,"children":1013},{},[1014],{"type":51,"value":1015},"src/app/routes.tsx",{"type":46,"tag":203,"props":1017,"children":1020},{"className":1018,"code":1019,"language":324,"meta":6},[322],"import { lazy, Suspense } from 'react'\nimport { Route, Routes } from 'react-router-dom'\nimport { RouteErrorBoundary } from '@/app/RouteErrorBoundary'\n\nconst DashboardPage = lazy(() => import('@/pages/DashboardPage'))\nconst OrdersPage = lazy(() => import('@/pages/OrdersPage'))\nconst SettingsPage = lazy(() => import('@/pages/SettingsPage'))\n\nfunction PageSuspense({ children }: { children: React.ReactNode }) {\n  return \u003CSuspense fallback={\u003Cdiv role=\"status\">Loading page\u003C/div>}>{children}\u003C/Suspense>\n}\n\nexport function AppRoutes() {\n  return (\n    \u003CRoutes>\n      \u003CRoute\n        path=\"/\"\n        element={\n          \u003CRouteErrorBoundary>\n            \u003CPageSuspense>\n              \u003CDashboardPage />\n            \u003C/PageSuspense>\n          \u003C/RouteErrorBoundary>\n        }\n      />\n      \u003CRoute\n        path=\"/orders\"\n        element={\n          \u003CRouteErrorBoundary>\n            \u003CPageSuspense>\n              \u003COrdersPage />\n            \u003C/PageSuspense>\n          \u003C/RouteErrorBoundary>\n        }\n      />\n      \u003CRoute\n        path=\"/settings\"\n        element={\n          \u003CRouteErrorBoundary>\n            \u003CPageSuspense>\n              \u003CSettingsPage />\n            \u003C/PageSuspense>\n          \u003C/RouteErrorBoundary>\n        }\n      />\n    \u003C/Routes>\n  )\n}\n",[1021],{"type":46,"tag":66,"props":1022,"children":1023},{"__ignoreMap":6},[1024],{"type":51,"value":1019},{"type":46,"tag":47,"props":1026,"children":1027},{},[1028,1030,1036,1038,1044,1046,1052],{"type":51,"value":1029},"Each page becomes its own chunk, the initial bundle shrinks to the shell plus the first route, and the rest loads on navigation. The ",{"type":46,"tag":66,"props":1031,"children":1033},{"className":1032},[],[1034],{"type":51,"value":1035},"RouteErrorBoundary",{"type":51,"value":1037}," wrap doubles up: a failed chunk download now shows the recovery UI instead of a white screen with a chunk-load error in the console. Run ",{"type":46,"tag":66,"props":1039,"children":1041},{"className":1040},[],[1042],{"type":51,"value":1043},"vite build",{"type":51,"value":1045}," with ",{"type":46,"tag":66,"props":1047,"children":1049},{"className":1048},[],[1050],{"type":51,"value":1051},"rollup-plugin-visualizer",{"type":51,"value":1053}," once after this change and you will see the chunks split out properly; if a chunk is still suspiciously large, the visualiser tells you which dependency is the culprit.",{"type":46,"tag":54,"props":1055,"children":1057},{"id":1056},"dimension-5-accessibility",[1058],{"type":51,"value":1059},"Dimension 5, accessibility",{"type":46,"tag":47,"props":1061,"children":1062},{},[1063,1065,1071,1073,1078,1080,1086],{"type":51,"value":1064},"The AI is good at visual fidelity and blind to keyboard fidelity. Generated components use ",{"type":46,"tag":66,"props":1066,"children":1068},{"className":1067},[],[1069],{"type":51,"value":1070},"\u003Cdiv onClick>",{"type":51,"value":1072}," instead of ",{"type":46,"tag":66,"props":1074,"children":1076},{"className":1075},[],[1077],{"type":51,"value":978},{"type":51,"value":1079},", drop landmarks because the model has no concept of page structure, and add ARIA attributes that contradict the implicit semantics of the underlying element. The fix is a pass that restores semantic HTML, focus management, and the few ARIA attributes that actually do work. The pattern in ",{"type":46,"tag":251,"props":1081,"children":1083},{"href":1082},"/blog/react-inversion-of-control-and-jsx-injection-via-context-api",[1084],{"type":51,"value":1085},"React Inversion of Control and JSX injection via Context API",{"type":51,"value":1087}," is one example of how to keep accessible markup intact while still giving consumers control over what gets rendered.",{"type":46,"tag":287,"props":1089,"children":1091},{"id":1090},"focus-management-semantic-landmarks-and-aria-the-ai-omitted",[1092],{"type":51,"value":1093},"Focus management, semantic landmarks, and aria the AI omitted",{"type":46,"tag":47,"props":1095,"children":1096},{},[1097],{"type":51,"value":1098},"A typical AI-generated modal looks plausible and is unusable with a keyboard. It renders into the document where the trigger lives, never moves focus into the dialog, never returns focus when closed, and the escape key does nothing. The production-grade version closes all four gaps.",{"type":46,"tag":47,"props":1100,"children":1101},{},[1102],{"type":46,"tag":197,"props":1103,"children":1104},{},[1105],{"type":51,"value":1106},"src/components/Modal.tsx",{"type":46,"tag":203,"props":1108,"children":1111},{"className":1109,"code":1110,"language":324,"meta":6},[322],"import { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\n\ninterface ModalProps {\n  isOpen: boolean\n  onClose: () => void\n  title: string\n  children: React.ReactNode\n}\n\nexport function Modal({ isOpen, onClose, title, children }: ModalProps) {\n  const dialogRef = useRef\u003CHTMLDivElement>(null)\n  const previousFocusRef = useRef\u003CHTMLElement | null>(null)\n  const titleId = useRef(`modal-title-${Math.random().toString(36).slice(2)}`)\n\n  useEffect(() => {\n    if (!isOpen) return\n    previousFocusRef.current = document.activeElement as HTMLElement | null\n    dialogRef.current?.focus()\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') onClose()\n    }\n    document.addEventListener('keydown', onKeyDown)\n\n    return () => {\n      document.removeEventListener('keydown', onKeyDown)\n      previousFocusRef.current?.focus()\n    }\n  }, [isOpen, onClose])\n\n  if (!isOpen) return null\n\n  return createPortal(\n    \u003Cdiv className=\"modal-backdrop\" onClick={onClose}>\n      \u003Cdiv\n        ref={dialogRef}\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId.current}\n        tabIndex={-1}\n        className=\"modal-dialog\"\n        onClick={event => event.stopPropagation()}\n      >\n        \u003Ch2 id={titleId.current}>{title}\u003C/h2>\n        {children}\n        \u003Cbutton type=\"button\" onClick={onClose}>\n          Close\n        \u003C/button>\n      \u003C/div>\n    \u003C/div>,\n    document.body,\n  )\n}\n",[1112],{"type":46,"tag":66,"props":1113,"children":1114},{"__ignoreMap":6},[1115],{"type":51,"value":1110},{"type":46,"tag":47,"props":1117,"children":1118},{},[1119,1121,1127,1129,1135,1137,1143],{"type":51,"value":1120},"Three behaviours snap into place. Focus moves into the dialog on open and returns to the trigger on close, the escape key closes the modal, and the dialog is announced by name through ",{"type":46,"tag":66,"props":1122,"children":1124},{"className":1123},[],[1125],{"type":51,"value":1126},"aria-labelledby",{"type":51,"value":1128},". The ",{"type":46,"tag":66,"props":1130,"children":1132},{"className":1131},[],[1133],{"type":51,"value":1134},"tabIndex={-1}",{"type":51,"value":1136}," on the dialog container is what lets the focus call land somewhere meaningful before the user tabs to the first interactive element. A full focus trap (cycling Tab and Shift+Tab) is the next step for production; reach for ",{"type":46,"tag":66,"props":1138,"children":1140},{"className":1139},[],[1141],{"type":51,"value":1142},"focus-trap-react",{"type":51,"value":1144}," rather than rewriting it by hand.",{"type":46,"tag":47,"props":1146,"children":1147},{},[1148,1150,1156],{"type":51,"value":1149},"While you are in the file, audit the page shell for semantic landmarks. The AI almost certainly nested everything under a single root ",{"type":46,"tag":66,"props":1151,"children":1153},{"className":1152},[],[1154],{"type":51,"value":1155},"\u003Cdiv>",{"type":51,"value":285},{"type":46,"tag":47,"props":1158,"children":1159},{},[1160],{"type":46,"tag":197,"props":1161,"children":1162},{},[1163],{"type":51,"value":1164},"src/app/AppShell.tsx",{"type":46,"tag":203,"props":1166,"children":1169},{"className":1167,"code":1168,"language":324,"meta":6},[322],"import { AppHeader } from '@/app/AppHeader'\nimport { AppNav } from '@/app/AppNav'\nimport { AppFooter } from '@/app/AppFooter'\n\ninterface AppShellProps {\n  children: React.ReactNode\n}\n\nexport function AppShell({ children }: AppShellProps) {\n  return (\n    \u003C>\n      \u003Ca href=\"#main-content\" className=\"skip-link\">\n        Skip to main content\n      \u003C/a>\n      \u003CAppHeader />\n      \u003Cdiv className=\"layout\">\n        \u003CAppNav />\n        \u003Cmain id=\"main-content\" tabIndex={-1}>\n          {children}\n        \u003C/main>\n      \u003C/div>\n      \u003CAppFooter />\n    \u003C/>\n  )\n}\n",[1170],{"type":46,"tag":66,"props":1171,"children":1172},{"__ignoreMap":6},[1173],{"type":51,"value":1168},{"type":46,"tag":47,"props":1175,"children":1176},{},[1177,1179,1185,1187,1192,1194,1199,1201,1207,1209,1214],{"type":51,"value":1178},"The skip link, the ",{"type":46,"tag":66,"props":1180,"children":1182},{"className":1181},[],[1183],{"type":51,"value":1184},"\u003Cmain>",{"type":51,"value":1186}," landmark, and the ",{"type":46,"tag":66,"props":1188,"children":1190},{"className":1189},[],[1191],{"type":51,"value":1134},{"type":51,"value":1193}," on ",{"type":46,"tag":66,"props":1195,"children":1197},{"className":1196},[],[1198],{"type":51,"value":1184},{"type":51,"value":1200}," together turn the page into something a screen reader user can navigate without listening to the entire header on every page load. One common ARIA mistake to retire while you are here: ",{"type":46,"tag":66,"props":1202,"children":1204},{"className":1203},[],[1205],{"type":51,"value":1206},"aria-label",{"type":51,"value":1208}," on a native ",{"type":46,"tag":66,"props":1210,"children":1212},{"className":1211},[],[1213],{"type":51,"value":978},{"type":51,"value":1215}," that already has text content is wasted; the visible text wins. ARIA is for what HTML cannot express, not a sprinkler system.",{"type":46,"tag":54,"props":1217,"children":1219},{"id":1218},"dimension-6-observability",[1220],{"type":51,"value":1221},"Dimension 6, observability",{"type":46,"tag":47,"props":1223,"children":1224},{},[1225,1227,1232,1234,1241,1243,1250],{"type":51,"value":1226},"You cannot fix what you cannot see. The AI-generated app ships with ",{"type":46,"tag":66,"props":1228,"children":1230},{"className":1229},[],[1231],{"type":51,"value":177},{"type":51,"value":1233}," for errors and nothing for analytics, which means every production bug starts with \"can you describe what you clicked?\". The minimum viable observability set-up is two libraries: ",{"type":46,"tag":251,"props":1235,"children":1238},{"href":1236,"rel":1237},"https://sentry.io/for/react/",[255],[1239],{"type":51,"value":1240},"Sentry",{"type":51,"value":1242}," for errors and performance, and ",{"type":46,"tag":251,"props":1244,"children":1247},{"href":1245,"rel":1246},"https://posthog.com/docs/libraries/react",[255],[1248],{"type":51,"value":1249},"PostHog",{"type":51,"value":1251}," for product analytics and session replay. Together they cover the question \"what happened\" and the question \"what was the user trying to do\" in one pass.",{"type":46,"tag":287,"props":1253,"children":1255},{"id":1254},"adding-sentry-or-posthog-event-hooks-at-the-seams",[1256],{"type":51,"value":1257},"Adding Sentry or PostHog event hooks at the seams",{"type":46,"tag":47,"props":1259,"children":1260},{},[1261],{"type":51,"value":1262},"Sentry first. The React 19 init is a single file, and the React Router integration adds route context to every error automatically.",{"type":46,"tag":47,"props":1264,"children":1265},{},[1266],{"type":46,"tag":197,"props":1267,"children":1268},{},[1269],{"type":51,"value":1270},"src/lib/sentry.ts",{"type":46,"tag":203,"props":1272,"children":1275},{"className":1273,"code":1274,"language":377,"meta":6},[375],"import * as Sentry from '@sentry/react'\n\nexport function initSentry(): void {\n  if (!import.meta.env.VITE_SENTRY_DSN) return\n\n  Sentry.init({\n    dsn: import.meta.env.VITE_SENTRY_DSN,\n    environment: import.meta.env.MODE,\n    release: import.meta.env.VITE_APP_VERSION,\n    integrations: [\n      Sentry.browserTracingIntegration(),\n      Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),\n    ],\n    tracesSampleRate: 0.1,\n    replaysSessionSampleRate: 0.05,\n    replaysOnErrorSampleRate: 1.0,\n  })\n}\n",[1276],{"type":46,"tag":66,"props":1277,"children":1278},{"__ignoreMap":6},[1279],{"type":51,"value":1274},{"type":46,"tag":47,"props":1281,"children":1282},{},[1283,1285,1291,1293,1299,1301,1307],{"type":51,"value":1284},"Three knobs matter at first. ",{"type":46,"tag":66,"props":1286,"children":1288},{"className":1287},[],[1289],{"type":51,"value":1290},"tracesSampleRate: 0.1",{"type":51,"value":1292}," keeps the performance volume manageable, ",{"type":46,"tag":66,"props":1294,"children":1296},{"className":1295},[],[1297],{"type":51,"value":1298},"replaysOnErrorSampleRate: 1.0",{"type":51,"value":1300}," ensures every error issue ships with a replay, and ",{"type":46,"tag":66,"props":1302,"children":1304},{"className":1303},[],[1305],{"type":51,"value":1306},"maskAllText: true",{"type":51,"value":1308}," is the default that keeps you out of trouble with personal data. The release and environment tags are what make the issue list filterable when you have more than one deploy stream.",{"type":46,"tag":47,"props":1310,"children":1311},{},[1312],{"type":51,"value":1313},"PostHog is the second half: identify the user, capture intent events at the seams, and let the product team see funnels rather than guess.",{"type":46,"tag":47,"props":1315,"children":1316},{},[1317],{"type":46,"tag":197,"props":1318,"children":1319},{},[1320],{"type":51,"value":1321},"src/lib/posthog.ts",{"type":46,"tag":203,"props":1323,"children":1326},{"className":1324,"code":1325,"language":377,"meta":6},[375],"import posthog from 'posthog-js'\n\nexport function initPostHog(): void {\n  if (!import.meta.env.VITE_POSTHOG_KEY) return\n\n  posthog.init(import.meta.env.VITE_POSTHOG_KEY, {\n    api_host: import.meta.env.VITE_POSTHOG_HOST ?? 'https://eu.i.posthog.com',\n    capture_pageview: true,\n    capture_pageleave: true,\n    session_recording: { maskAllInputs: true },\n  })\n}\n\nexport function identifyUser(userId: string, traits: Record\u003Cstring, unknown>): void {\n  posthog.identify(userId, traits)\n}\n\nexport function trackEvent(name: string, properties?: Record\u003Cstring, unknown>): void {\n  posthog.capture(name, properties)\n}\n",[1327],{"type":46,"tag":66,"props":1328,"children":1329},{"__ignoreMap":6},[1330],{"type":51,"value":1325},{"type":46,"tag":47,"props":1332,"children":1333},{},[1334],{"type":51,"value":1335},"The three seams worth instrumenting first are authentication (sign-up, sign-in, sign-out), the application's equivalent of checkout (the primary conversion event), and the top failure route identified by Sentry. Wiring those three captures gives you a funnel that maps directly on to the bug stream and lets you ask \"did the users who hit this error also drop out of the funnel?\" without writing custom analytics code. Everything else can wait until you have a real product question to answer.",{"type":46,"tag":47,"props":1337,"children":1338},{},[1339],{"type":51,"value":1340},"The wiring at the app root is two lines.",{"type":46,"tag":47,"props":1342,"children":1343},{},[1344],{"type":46,"tag":197,"props":1345,"children":1346},{},[1347],{"type":51,"value":1348},"src/app/AppProviders.tsx",{"type":46,"tag":203,"props":1350,"children":1353},{"className":1351,"code":1352,"language":324,"meta":6},[322],"import { useEffect } from 'react'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { PostHogProvider } from '@posthog/react'\nimport posthog from 'posthog-js'\nimport { initSentry } from '@/lib/sentry'\nimport { initPostHog } from '@/lib/posthog'\n\nconst queryClient = new QueryClient({\n  defaultOptions: { queries: { staleTime: 30_000, retry: 2 } },\n})\n\ninterface AppProvidersProps {\n  children: React.ReactNode\n}\n\nexport function AppProviders({ children }: AppProvidersProps) {\n  useEffect(() => {\n    initSentry()\n    initPostHog()\n  }, [])\n\n  return (\n    \u003CPostHogProvider client={posthog}>\n      \u003CQueryClientProvider client={queryClient}>{children}\u003C/QueryClientProvider>\n    \u003C/PostHogProvider>\n  )\n}\n",[1354],{"type":46,"tag":66,"props":1355,"children":1356},{"__ignoreMap":6},[1357],{"type":51,"value":1352},{"type":46,"tag":47,"props":1359,"children":1360},{},[1361,1362,1367],{"type":51,"value":547},{"type":46,"tag":66,"props":1363,"children":1365},{"className":1364},[],[1366],{"type":51,"value":71},{"type":51,"value":1368}," keeps the init calls out of the render path so React StrictMode does not double-init the SDKs in development. Once this lands, the loop closes: an error in production raises a Sentry issue with a replay, the PostHog funnel shows whether the error correlated with a drop-off, and the on-call engineer has both signals on the same incident timeline.",{"type":46,"tag":54,"props":1370,"children":1372},{"id":1371},"decision-recap-the-audit-as-a-repeatable-workflow",[1373],{"type":51,"value":1374},"Decision recap, the audit as a repeatable workflow",{"type":46,"tag":47,"props":1376,"children":1377},{},[1378],{"type":51,"value":1379},"The six-dimension pass is a checklist a tech lead can paste into a PR template. Run it once per file before any AI-generated React code ships to a real user.",{"type":46,"tag":1381,"props":1382,"children":1383},"table",{},[1384,1413],{"type":46,"tag":1385,"props":1386,"children":1387},"thead",{},[1388],{"type":46,"tag":1389,"props":1390,"children":1391},"tr",{},[1392,1398,1403,1408],{"type":46,"tag":1393,"props":1394,"children":1395},"th",{},[1396],{"type":51,"value":1397},"#",{"type":46,"tag":1393,"props":1399,"children":1400},{},[1401],{"type":51,"value":1402},"Dimension",{"type":46,"tag":1393,"props":1404,"children":1405},{},[1406],{"type":51,"value":1407},"First fix to apply",{"type":46,"tag":1393,"props":1409,"children":1410},{},[1411],{"type":51,"value":1412},"Failure mode it eliminates",{"type":46,"tag":1414,"props":1415,"children":1416},"tbody",{},[1417,1441,1464,1506,1529,1552],{"type":46,"tag":1389,"props":1418,"children":1419},{},[1420,1426,1431,1436],{"type":46,"tag":1421,"props":1422,"children":1423},"td",{},[1424],{"type":51,"value":1425},"1",{"type":46,"tag":1421,"props":1427,"children":1428},{},[1429],{"type":51,"value":1430},"Data layer",{"type":46,"tag":1421,"props":1432,"children":1433},{},[1434],{"type":51,"value":1435},"TanStack Query or RTK Query, centralised API client",{"type":46,"tag":1421,"props":1437,"children":1438},{},[1439],{"type":51,"value":1440},"Stale data, lost retries, hard-coded URLs",{"type":46,"tag":1389,"props":1442,"children":1443},{},[1444,1449,1454,1459],{"type":46,"tag":1421,"props":1445,"children":1446},{},[1447],{"type":51,"value":1448},"2",{"type":46,"tag":1421,"props":1450,"children":1451},{},[1452],{"type":51,"value":1453},"Error handling",{"type":46,"tag":1421,"props":1455,"children":1456},{},[1457],{"type":51,"value":1458},"Route boundary plus a three-channel error handler",{"type":46,"tag":1421,"props":1460,"children":1461},{},[1462],{"type":51,"value":1463},"White-screen crashes, silent failures",{"type":46,"tag":1389,"props":1465,"children":1466},{},[1467,1472,1477,1494],{"type":46,"tag":1421,"props":1468,"children":1469},{},[1470],{"type":51,"value":1471},"3",{"type":46,"tag":1421,"props":1473,"children":1474},{},[1475],{"type":51,"value":1476},"Type safety",{"type":46,"tag":1421,"props":1478,"children":1479},{},[1480,1485,1487,1492],{"type":46,"tag":66,"props":1481,"children":1483},{"className":1482},[],[1484],{"type":51,"value":642},{"type":51,"value":1486}," plus ",{"type":46,"tag":66,"props":1488,"children":1490},{"className":1489},[],[1491],{"type":51,"value":79},{"type":51,"value":1493},", Zod at the boundary",{"type":46,"tag":1421,"props":1495,"children":1496},{},[1497,1499,1504],{"type":51,"value":1498},"Runtime ",{"type":46,"tag":66,"props":1500,"children":1502},{"className":1501},[],[1503],{"type":51,"value":744},{"type":51,"value":1505}," errors, schema drift",{"type":46,"tag":1389,"props":1507,"children":1508},{},[1509,1514,1519,1524],{"type":46,"tag":1421,"props":1510,"children":1511},{},[1512],{"type":51,"value":1513},"4",{"type":46,"tag":1421,"props":1515,"children":1516},{},[1517],{"type":51,"value":1518},"Performance",{"type":46,"tag":1421,"props":1520,"children":1521},{},[1522],{"type":51,"value":1523},"Stable callbacks, route-level lazy loading",{"type":46,"tag":1421,"props":1525,"children":1526},{},[1527],{"type":51,"value":1528},"Wasted re-renders, oversized initial bundle",{"type":46,"tag":1389,"props":1530,"children":1531},{},[1532,1537,1542,1547],{"type":46,"tag":1421,"props":1533,"children":1534},{},[1535],{"type":51,"value":1536},"5",{"type":46,"tag":1421,"props":1538,"children":1539},{},[1540],{"type":51,"value":1541},"Accessibility",{"type":46,"tag":1421,"props":1543,"children":1544},{},[1545],{"type":51,"value":1546},"Semantic landmarks, focus management on modals",{"type":46,"tag":1421,"props":1548,"children":1549},{},[1550],{"type":51,"value":1551},"Keyboard and screen-reader users locked out",{"type":46,"tag":1389,"props":1553,"children":1554},{},[1555,1560,1565,1570],{"type":46,"tag":1421,"props":1556,"children":1557},{},[1558],{"type":51,"value":1559},"6",{"type":46,"tag":1421,"props":1561,"children":1562},{},[1563],{"type":51,"value":1564},"Observability",{"type":46,"tag":1421,"props":1566,"children":1567},{},[1568],{"type":51,"value":1569},"Sentry plus PostHog at auth, payment, and top failure routes",{"type":46,"tag":1421,"props":1571,"children":1572},{},[1573],{"type":51,"value":1574},"\"Cannot reproduce\" tickets, blind incidents",{"type":46,"tag":47,"props":1576,"children":1577},{},[1578],{"type":51,"value":1579},"The pattern under the table is the same in every dimension. In each case, the AI produced a plausible component shape and a wrong data, error, or instrumentation layer. Your job is to keep the shape, lift the layer out, and rewrite only the seam that fails the audit. That is much faster than a wholesale rewrite, and it preserves whatever taste the model got right about the UI.",{"type":46,"tag":47,"props":1581,"children":1582},{},[1583,1585,1592,1594,1600],{"type":51,"value":1584},"For teams who want this pass automated into their workflow rather than copy-pasted into PRs, the same dimensions form the spine of ",{"type":46,"tag":251,"props":1586,"children":1589},{"href":1587,"rel":1588},"https://theroadtoenterprise.com/books/vibe-code-to-production",[255],[1590],{"type":51,"value":1591},"Vibe Code to Production",{"type":51,"value":1593},", my book on taking vibe-coded prototypes to production with React, TypeScript, and AI assistants. The article is the public surface; the book is the workflow. Until then, ",{"type":46,"tag":251,"props":1595,"children":1597},{"href":1596},"/blog/onboarding-to-new-codebase-with-ai-tools",[1598],{"type":51,"value":1599},"onboarding to a new codebase with AI tools",{"type":51,"value":1601}," is the sibling read for applying the same dimensional thinking to a codebase you did not write yourself.",{"title":6,"searchDepth":1603,"depth":1603,"links":1604},2,[1605,1606,1607,1612,1616,1620,1624,1627,1630],{"id":56,"depth":1603,"text":59},{"id":148,"depth":1603,"text":151},{"id":221,"depth":1603,"text":224,"children":1608},[1609,1611],{"id":289,"depth":1610,"text":292},3,{"id":398,"depth":1610,"text":401},{"id":456,"depth":1603,"text":459,"children":1613},[1614,1615],{"id":482,"depth":1610,"text":485},{"id":566,"depth":1610,"text":569},{"id":628,"depth":1603,"text":631,"children":1617},[1618,1619],{"id":655,"depth":1610,"text":658},{"id":790,"depth":1610,"text":793},{"id":854,"depth":1603,"text":857,"children":1621},[1622,1623],{"id":874,"depth":1610,"text":877},{"id":991,"depth":1610,"text":994},{"id":1056,"depth":1603,"text":1059,"children":1625},[1626],{"id":1090,"depth":1610,"text":1093},{"id":1218,"depth":1603,"text":1221,"children":1628},[1629],{"id":1254,"depth":1610,"text":1257},{"id":1371,"depth":1603,"text":1374},"markdown",1781787433903]