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.
TL;DR, the six-dimension audit pass
Hardening AI-generated React code is a methodical pass across six dimensions where the model defaults to demo-grade patterns: replace raw useEffect fetches with TanStack Query or RTK Query, wrap routes in error boundaries that log and recover, turn on strict TypeScript with noUncheckedIndexedAccess, 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, @sentry/react 10.x, and posthog-js 1.375+ with @posthog/react 1.9.x. Run it once per file before the prototype touches a real user.
The six dimensions, one verb each:
- Data layer, replace.
- Error handling, contain.
- Type safety, tighten.
- Performance, measure.
- Accessibility, restore.
- Observability, instrument.
The starting point, an AI-generated React app
Most AI-generated React apps ship with three failure modes baked in: a raw fetch inside useEffect, a console.error in the catch branch, and implicit any 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.
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.
src/features/orders/RecentOrders.jsx
import { useEffect, useState } from 'react'
export default function RecentOrders() {
const [orders, setOrders] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch('https://api.example.com/orders/recent')
.then(res => res.json())
.then(data => {
setOrders(data)
setLoading(false)
})
.catch(err => {
console.error(err)
setLoading(false)
})
}, [])
if (loading) return <div>Loading...</div>
return (
<div>
<h2>Recent orders</h2>
<ul>
{orders.map(o => (
<li key={o.id}>
{o.customer} - {o.total}
</li>
))}
</ul>
</div>
)
}
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.
Dimension 1, the data layer
The data layer is where AI-generated React collapses first. Models default to fetch inside useEffect 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 useEffect for data and delegate caching, retries, and request deduplication to a server-state library. For most teams in 2026 that is TanStack Query 5.x; for teams already running Redux Toolkit, RTK Query 2.x. The trade-offs sit in RTK Query vs TanStack Query, and a deeper read on TanStack Query as an async state manager lives in this companion piece.
Replacing useEffect + fetch with TanStack Query or RTK Query
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 Loading... forever, because the catch branch swallows the failure silently.
The TanStack Query rewrite collapses both problems and shrinks the component.
src/features/orders/RecentOrders.tsx
import { useQuery } from '@tanstack/react-query'
import { ordersApi } from '@/api/orders'
import type { Order } from '@/api/orders'
export default function RecentOrders() {
const { data, isLoading, isError, error, refetch } = useQuery<Order[], Error>({
queryKey: ['orders', 'recent'],
queryFn: ordersApi.recent,
staleTime: 30_000,
retry: 2,
})
if (isLoading) return <div role="status">Loading recent orders</div>
if (isError) {
return (
<div role="alert">
<p>Could not load recent orders. {error.message}</p>
<button type="button" onClick={() => refetch()}>
Try again
</button>
</div>
)
}
return (
<section aria-labelledby="recent-orders-heading">
<h2 id="recent-orders-heading">Recent orders</h2>
<ul>
{data?.map(o => (
<li key={o.id}>
{o.customer}, {o.total}
</li>
))}
</ul>
</section>
)
}
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 queryKey. 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 QueryClientProvider at the app root and staleTime tuned to the data's volatility and most "the dashboard is stale" tickets disappear.
The same idea expressed in RTK Query if you are already invested in Redux Toolkit.
src/api/ordersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Order } from '@/api/orders'
export const ordersApi = createApi({
reducerPath: 'ordersApi',
baseQuery: fetchBaseQuery({
baseUrl: import.meta.env.VITE_API_BASE_URL,
}),
tagTypes: ['Orders'],
endpoints: builder => ({
getRecentOrders: builder.query<Order[], void>({
query: () => 'orders/recent',
providesTags: ['Orders'],
}),
}),
})
export const { useGetRecentOrdersQuery } = ordersApi
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 Zustand vs Redux Toolkit for the trade-off there.
Centralising the API client and base URL
The AI-generated snippet hard-coded https://api.example.com/orders/recent. 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.
src/api/client.ts
import axios, { AxiosError, AxiosInstance } from 'axios'
const baseURL = import.meta.env.VITE_API_BASE_URL
if (!baseURL) {
throw new Error('VITE_API_BASE_URL is not set')
}
export const apiClient: AxiosInstance = axios.create({
baseURL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
})
apiClient.interceptors.response.use(
response => response,
(error: AxiosError) => {
if (error.response?.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorised'))
}
return Promise.reject(error)
},
)
src/api/orders.ts
import { apiClient } from '@/api/client'
export interface Order {
id: string
customer: string
total: string
}
export const ordersApi = {
recent: async (): Promise<Order[]> => {
const { data } = await apiClient.get<Order[]>('/orders/recent')
return data
},
}
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.
Dimension 2, error handling and resilience
The AI-generated React app handles errors the way the model saw most demo apps handle them: a try/catch that calls console.error, 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.
Error boundaries at the route and feature level
React 19 still ships class-based error boundaries as the official primitive, and react-error-boundary 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.
src/app/RouteErrorBoundary.tsx
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import * as Sentry from '@sentry/react'
import { useNavigate } from 'react-router-dom'
function RouteFallback({ error, resetErrorBoundary }: FallbackProps) {
const navigate = useNavigate()
return (
<div role="alert" className="route-error">
<h1>Something went wrong on this page</h1>
<p>{error.message}</p>
<button type="button" onClick={resetErrorBoundary}>
Reload this page
</button>
<button type="button" onClick={() => navigate('/')}>
Go home
</button>
</div>
)
}
export function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
FallbackComponent={RouteFallback}
onError={(error, info) => {
Sentry.captureException(error, { extra: { componentStack: info.componentStack } })
}}
>
{children}
</ErrorBoundary>
)
}
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.
The feature-level boundary is the same component with a smaller, in-context fallback.
src/app/FeatureErrorBoundary.tsx
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import * as Sentry from '@sentry/react'
function FeatureFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="feature-error">
<p>This section is temporarily unavailable.</p>
<button type="button" onClick={resetErrorBoundary}>
Retry
</button>
</div>
)
}
export function FeatureErrorBoundary({
name,
children,
}: {
name: string
children: React.ReactNode
}) {
return (
<ErrorBoundary
FallbackComponent={FeatureFallback}
onError={error => {
Sentry.captureException(error, { tags: { feature: name } })
}}
>
{children}
</ErrorBoundary>
)
}
The name tag is the payoff. When the recent orders widget throws, the Sentry issue carries feature: recent-orders 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.
Toast plus log plus report, beyond console.error
console.error 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.
src/lib/errorHandler.ts
import * as Sentry from '@sentry/react'
import { toast } from 'sonner'
interface ReportOptions {
userMessage?: string
context?: Record<string, unknown>
silent?: boolean
}
export function reportError(error: unknown, options: ReportOptions = {}): void {
const { userMessage, context, silent } = options
const err = error instanceof Error ? error : new Error(String(error))
if (!silent && userMessage) {
toast.error(userMessage)
}
console.error('[app-error]', err.message, context ?? {})
Sentry.captureException(err, {
extra: context,
})
}
The handler accepts a userMessage because not every error needs a toast, and a silent 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 reportError(err, { userMessage: 'Could not save changes', context: { orderId } }) 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.
Dimension 3, type safety
The AI-generated React app usually arrives with TypeScript turned on and strict 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 null 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.
Removing implicit any from generated code
The first move is a tsconfig.json change.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
strict brings in noImplicitAny, strictNullChecks, and four other flags that the AI's generated code probably ignores. noUncheckedIndexedAccess is the high-value extra: it forces you to handle the fact that array[i] might be undefined, which is the most common production bug in a list-heavy React app. exactOptionalPropertyTypes catches the difference between a prop being absent and a prop being undefined, which matters as soon as you talk to a backend that distinguishes the two.
The compiler will scream the first time. That is the point. The fixes are usually narrow.
src/features/orders/orderTotal.ts
import type { Order } from '@/api/orders'
export function formatCustomerName(order: Order | undefined): string {
if (!order) return 'Unknown customer'
return order.customer.trim() || 'Unnamed customer'
}
export function firstOrderTotal(orders: Order[]): string {
const first = orders[0]
if (!first) return '0.00'
return first.total
}
Without noUncheckedIndexedAccess, orders[0].total 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.
Inferring API types from a schema, not from the response
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 Zod closes the loop: define the shape once, validate at the boundary, infer the TypeScript type from the same definition.
src/api/orders.ts
import { z } from 'zod'
import { apiClient } from '@/api/client'
export const OrderSchema = z.object({
id: z.string(),
customer: z.string(),
total: z.string(),
createdAt: z.string().datetime(),
})
export const OrdersResponseSchema = z.array(OrderSchema)
export type Order = z.infer<typeof OrderSchema>
export const ordersApi = {
recent: async (): Promise<Order[]> => {
const { data } = await apiClient.get('/orders/recent')
return OrdersResponseSchema.parse(data)
},
}
Two things changed. The Order type is now derived from OrderSchema, 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 undefined 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.
Dimension 4, performance
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 React useMemo docs carry the framework team's own warning that the hook is an optimisation and not a default.
Memoisation and stable references the AI missed
A typical AI-generated component looks like this, with memoisation in the wrong places.
src/features/orders/OrdersList.tsx (before)
import { useMemo } from 'react'
import type { Order } from '@/api/orders'
interface Props {
orders: Order[]
onSelect: (id: string) => void
}
export function OrdersList({ orders, onSelect }: Props) {
const heading = useMemo(() => 'Recent orders', [])
const sortedOrders = useMemo(
() => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),
[orders],
)
return (
<section>
<h2>{heading}</h2>
<ul>
{sortedOrders.map(o => (
<li key={o.id} onClick={() => onSelect(o.id)}>
{o.customer}
</li>
))}
</ul>
</section>
)
}
The heading memoisation is pure waste; it caches a string literal that the JavaScript engine deduplicates anyway. Sorting inside sortedOrders is the memo that earns its keep, because the comparison is O(n log n) on every render. Look at the inline arrow on onClick, though, and you find the actual performance bug: every list item gets a fresh function on every parent render, which breaks any React.memo wrapping the list items might have. The fix is to drop the useless memo, keep the useful one, and stabilise the click handler.
src/features/orders/OrdersList.tsx (after)
import { useCallback, useMemo } from 'react'
import type { Order } from '@/api/orders'
interface Props {
orders: Order[]
onSelect: (id: string) => void
}
interface RowProps {
order: Order
onSelect: (id: string) => void
}
const OrderRow = ({ order, onSelect }: RowProps) => {
const handleClick = useCallback(() => onSelect(order.id), [order.id, onSelect])
return (
<li>
<button type="button" onClick={handleClick}>
{order.customer}
</button>
</li>
)
}
export function OrdersList({ orders, onSelect }: Props) {
const sortedOrders = useMemo(
() => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),
[orders],
)
return (
<section>
<h2>Recent orders</h2>
<ul>
{sortedOrders.map(order => (
<OrderRow key={order.id} order={order} onSelect={onSelect} />
))}
</ul>
</section>
)
}
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. useCallback here is not a magic wand for performance; it is a stable-reference tool that lets React.memo actually do its job. The <button> element also gets us keyboard accessibility for free, which the original <li onClick> 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.
Bundle size and route-level code splitting
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 React.lazy is the single highest-impact performance fix in a React SPA.
src/app/routes.tsx
import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { RouteErrorBoundary } from '@/app/RouteErrorBoundary'
const DashboardPage = lazy(() => import('@/pages/DashboardPage'))
const OrdersPage = lazy(() => import('@/pages/OrdersPage'))
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))
function PageSuspense({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<div role="status">Loading page</div>}>{children}</Suspense>
}
export function AppRoutes() {
return (
<Routes>
<Route
path="/"
element={
<RouteErrorBoundary>
<PageSuspense>
<DashboardPage />
</PageSuspense>
</RouteErrorBoundary>
}
/>
<Route
path="/orders"
element={
<RouteErrorBoundary>
<PageSuspense>
<OrdersPage />
</PageSuspense>
</RouteErrorBoundary>
}
/>
<Route
path="/settings"
element={
<RouteErrorBoundary>
<PageSuspense>
<SettingsPage />
</PageSuspense>
</RouteErrorBoundary>
}
/>
</Routes>
)
}
Each page becomes its own chunk, the initial bundle shrinks to the shell plus the first route, and the rest loads on navigation. The RouteErrorBoundary 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 vite build with rollup-plugin-visualizer 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.
Dimension 5, accessibility
The AI is good at visual fidelity and blind to keyboard fidelity. Generated components use <div onClick> instead of <button>, 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 React Inversion of Control and JSX injection via Context API is one example of how to keep accessible markup intact while still giving consumers control over what gets rendered.
Focus management, semantic landmarks, and aria the AI omitted
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.
src/components/Modal.tsx
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
const titleId = useRef(`modal-title-${Math.random().toString(36).slice(2)}`)
useEffect(() => {
if (!isOpen) return
previousFocusRef.current = document.activeElement as HTMLElement | null
dialogRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
previousFocusRef.current?.focus()
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId.current}
tabIndex={-1}
className="modal-dialog"
onClick={event => event.stopPropagation()}
>
<h2 id={titleId.current}>{title}</h2>
{children}
<button type="button" onClick={onClose}>
Close
</button>
</div>
</div>,
document.body,
)
}
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 aria-labelledby. The tabIndex={-1} 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 focus-trap-react rather than rewriting it by hand.
While you are in the file, audit the page shell for semantic landmarks. The AI almost certainly nested everything under a single root <div>.
src/app/AppShell.tsx
import { AppHeader } from '@/app/AppHeader'
import { AppNav } from '@/app/AppNav'
import { AppFooter } from '@/app/AppFooter'
interface AppShellProps {
children: React.ReactNode
}
export function AppShell({ children }: AppShellProps) {
return (
<>
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<AppHeader />
<div className="layout">
<AppNav />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</div>
<AppFooter />
</>
)
}
The skip link, the <main> landmark, and the tabIndex={-1} on <main> 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: aria-label on a native <button> that already has text content is wasted; the visible text wins. ARIA is for what HTML cannot express, not a sprinkler system.
Dimension 6, observability
You cannot fix what you cannot see. The AI-generated app ships with console.error 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: Sentry for errors and performance, and PostHog 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.
Adding Sentry or PostHog event hooks at the seams
Sentry first. The React 19 init is a single file, and the React Router integration adds route context to every error automatically.
src/lib/sentry.ts
import * as Sentry from '@sentry/react'
export function initSentry(): void {
if (!import.meta.env.VITE_SENTRY_DSN) return
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
release: import.meta.env.VITE_APP_VERSION,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
],
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.05,
replaysOnErrorSampleRate: 1.0,
})
}
Three knobs matter at first. tracesSampleRate: 0.1 keeps the performance volume manageable, replaysOnErrorSampleRate: 1.0 ensures every error issue ships with a replay, and maskAllText: true 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.
PostHog is the second half: identify the user, capture intent events at the seams, and let the product team see funnels rather than guess.
src/lib/posthog.ts
import posthog from 'posthog-js'
export function initPostHog(): void {
if (!import.meta.env.VITE_POSTHOG_KEY) return
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: import.meta.env.VITE_POSTHOG_HOST ?? 'https://eu.i.posthog.com',
capture_pageview: true,
capture_pageleave: true,
session_recording: { maskAllInputs: true },
})
}
export function identifyUser(userId: string, traits: Record<string, unknown>): void {
posthog.identify(userId, traits)
}
export function trackEvent(name: string, properties?: Record<string, unknown>): void {
posthog.capture(name, properties)
}
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.
The wiring at the app root is two lines.
src/app/AppProviders.tsx
import { useEffect } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PostHogProvider } from '@posthog/react'
import posthog from 'posthog-js'
import { initSentry } from '@/lib/sentry'
import { initPostHog } from '@/lib/posthog'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 2 } },
})
interface AppProvidersProps {
children: React.ReactNode
}
export function AppProviders({ children }: AppProvidersProps) {
useEffect(() => {
initSentry()
initPostHog()
}, [])
return (
<PostHogProvider client={posthog}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</PostHogProvider>
)
}
The useEffect 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.
Decision recap, the audit as a repeatable workflow
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.
| # | Dimension | First fix to apply | Failure mode it eliminates |
|---|---|---|---|
| 1 | Data layer | TanStack Query or RTK Query, centralised API client | Stale data, lost retries, hard-coded URLs |
| 2 | Error handling | Route boundary plus a three-channel error handler | White-screen crashes, silent failures |
| 3 | Type safety | strict plus noUncheckedIndexedAccess, Zod at the boundary | Runtime undefined errors, schema drift |
| 4 | Performance | Stable callbacks, route-level lazy loading | Wasted re-renders, oversized initial bundle |
| 5 | Accessibility | Semantic landmarks, focus management on modals | Keyboard and screen-reader users locked out |
| 6 | Observability | Sentry plus PostHog at auth, payment, and top failure routes | "Cannot reproduce" tickets, blind incidents |
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.
For teams who want this pass automated into their workflow rather than copy-pasted into PRs, the same dimensions form the spine of Vibe Code to Production, 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, onboarding to a new codebase with AI tools is the sibling read for applying the same dimensional thinking to a codebase you did not write yourself.


