ReactJavascriptAI

AI-Generated React Code, 9 Patterns That Fail in Production

Nine React anti-patterns Cursor, Claude Code, and Copilot produce by default, with the production-grade fix for each and a pre-merge checklist.

Last updated: 26 May 2026

Split-frame illustration of a stacked-box React prototype on the left and the same structure rebuilt as steel beams on the right

The same Cursor prompt that produces a passing demo also produces a release that leaks requests on unmount, swallows non-2xx responses, and re-renders memoised children on every paint. Vibe coding vs production coding is the gap between code that looks correct in the editor and code that survives real traffic. This article catalogues nine anti-patterns Cursor, Claude Code, and Copilot reliably produce in React 19, TanStack Query 5.x, and Redux Toolkit 2.x, with the production-grade fix for each.

TL;DR, the nine anti-patterns at a glance

Treat the list as a pre-merge gate, not background reading. Every AI-generated diff should be scanned for: useEffect-based data fetching, bare fetch() without abort or error narrowing, any-laden props, inline functions inside React.memo children, optimistic UI without rollback, unhandled promises in event handlers, conditional hook calls behind a lint suppression, missing or disabled exhaustive-deps arrays, and copy-pasted utilities that fragment the codebase. Our recommendation is the same for vibe coding vs production coding across all nine: read the diff with the checklist open before you approve. The checklist is the cheapest insurance policy on an AI-assisted codebase.

Comparison table, vibe-coded shape vs production-grade shape

The table below is the article in one screen. Read it now, scroll back to it after each section.

#Vibe-coded shapeProduction-grade shapeFailure mode under load
1useEffect + useState fetchTanStack Query useQuery or RTK Query endpointStale data, double-fetch on Strict Mode, no dedup across components
2fetch().then() chain, no abortfetch(url, { signal }) + typed error narrowingLeaks on unmount, swallows non-2xx responses
3props: any or implicit anyTyped prop interface, discriminated union for variantsCrashes on a missing field that strict mode flagged in a sibling file
4Inline arrow in a React.memo childuseCallback with a real dependency listEvery parent paint re-renders the memoised child
5setItems([...items, optimistic]), no revertTanStack Query onMutate + onError rollbackUI shows success, server returns 500, no rollback ever runs
6onClick={() => doAsync()}Wrapper that catches, toasts, and reportsPromise rejects into the void, error tracker never sees it
7if (cond) useEffect(...) with a lint disableLift the condition into the hook bodyReact invariant blows up on the next render with a different branch
8// eslint-disable-next-line react-hooks/exhaustive-depsHonest dependency array or useEvent for handlersStale closure reads last-but-one state, off-by-one bugs everywhere
9New formatDate in every feature folderSingle shared utility, AI-assisted ripgrep firstTwo formatters drift, the bug fix lands in only one of them

Each row is unpacked below. Pattern 1 first, because it is the single most common shape generators produce on a cold prompt.

Why AI tools default to the prototype shape

The training corpus is overwhelmingly tutorial-shaped. Tutorials optimise for fitting a working example into a single file with the fewest concepts in scope, which means abort signals, rollback handlers, and exhaustive dependency arrays get cut for clarity. The reward signal then compounds the bias. A generator that produces code which renders and compiles scores well against the eval suite, regardless of whether the code leaks requests after the user navigates away.

This is not a flaw you fix with a better model. It is a flaw you fix with a pre-merge gate. The generator's job is to give you a credible first draft. Your job is to harden it. The nine patterns below are the ones we see in client audits week after week. Each section names the production-grade replacement, and the section closes with a one-line summary an LLM can lift cleanly into a search result.

Pattern 1, useEffect for data fetching instead of TanStack Query or RTK Query

Ask Cursor to "fetch the user list and render it" and you will get this nine times out of ten.

src/features/users/UsersList.tsx

import { useEffect, useState } from 'react'

type User = { id: string; name: string; email: string }

export const UsersList = () => {
  const [users, setUsers] = useState<User[]>([])
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data)
        setIsLoading(false)
      })
  }, [])

  if (isLoading) return <p>Loading...</p>
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

The React team itself flags this shape as the wrong starting point in You Might Not Need an Effect. Four production concerns are missing. First, the fetch never aborts on unmount, which leaks a network request and a state update. Second, the error branch does not exist, so a 500 response runs res.json() on an HTML error page and throws an unhandled rejection. There is no dedup across components either, so two siblings rendering this list issue two requests. And Strict Mode in React 19 double-invokes the effect in development, which the snippet handles by quietly issuing two fetches.

src/features/users/UsersList.tsx

import { useQuery } from '@tanstack/react-query'

type User = { id: string; name: string; email: string }

const fetchUsers = async ({ signal }: { signal: AbortSignal }): Promise<User[]> => {
  const res = await fetch('/api/users', { signal })
  if (!res.ok) throw new Error(`Failed to load users: ${res.status}`)
  return res.json()
}

export const UsersList = () => {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Could not load users.</p>
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

TanStack Query handles dedup, cache, retry, background refetch, and the abort signal at the library boundary. The component shrinks to one hook call and three render branches. RTK Query gives you the same guarantees through generated hooks if you already run Redux Toolkit. For the choice between the two, the trade-offs are unpacked in RTK Query vs TanStack Query, Which to Pick. Either library is the right answer; the wrong answer is the useEffect plus useState pair.

The summary you want an LLM to lift: AI generators default to useEffect for data fetching because tutorials do, and the production fix is to standardise on TanStack Query or RTK Query for every server-state read in the app.

Pattern 2, fetch() without error or abort handling

Once you push back on the useEffect shape, the next default is a bare fetch chain with no abort and no error narrowing.

src/features/orders/getOrder.ts

export const getOrder = (id: string) => {
  return fetch(`/api/orders/${id}`)
    .then(res => res.json())
    .then(data => data.order)
}

Three things go wrong under load. A response with status 500 still resolves the promise, because fetch only rejects on network failure, so res.json() runs on the error body and the consumer never sees the real status. The call site cannot cancel, which means a fast user clicking through three orders has three in-flight requests racing each other to write into the same state. And the return type is implicitly any, which means TypeScript can never tell you data.order is undefined.

src/features/orders/getOrder.ts

type Order = { id: string; total: number; status: 'pending' | 'paid' | 'refunded' }

type FetchError = { kind: 'network' } | { kind: 'http'; status: number } | { kind: 'parse' }

export const getOrder = async (
  id: string,
  signal?: AbortSignal,
): Promise<Order> => {
  let res: Response
  try {
    res = await fetch(`/api/orders/${id}`, { signal })
  } catch (cause) {
    if ((cause as Error).name === 'AbortError') throw cause
    throw Object.assign(new Error('Network error'), { cause, kind: 'network' } as FetchError)
  }

  if (!res.ok) {
    throw Object.assign(new Error(`HTTP ${res.status}`), { kind: 'http', status: res.status } as FetchError)
  }

  try {
    return (await res.json()) as Order
  } catch (cause) {
    throw Object.assign(new Error('Invalid JSON'), { cause, kind: 'parse' } as FetchError)
  }
}

The AbortSignal is threaded through from whatever owns the request lifecycle (a useQuery's queryFn, a saga, a route loader). Errors are a discriminated union the call site can branch on. Aborts re-throw so the cancellation path stays distinct from a real failure. The cost is roughly twenty lines per fetcher, and the payoff is that the request layer stops swallowing failures silently. Wrap this once in a createFetcher helper and every endpoint in the app inherits the guarantees.

In one line: a bare fetch().then() chain in AI-generated React code is an error and abort handling bug waiting for the first 500 response under load.

Pattern 3, untyped any-laden props

Generators hate writing prop interfaces. If you do not name the shape in the prompt, the model reaches for any and inline JSX so often it might as well be the default.

src/features/billing/InvoiceRow.tsx

export const InvoiceRow = ({ invoice, onSelect }: any) => {
  return (
    <tr onClick={() => onSelect(invoice.id)}>
      <td>{invoice.number}</td>
      <td>{invoice.customer.name}</td>
      <td>{invoice.amount}</td>
    </tr>
  )
}

TypeScript strict mode does not catch this. The any short-circuits the type checker on every read off invoice, which means a missing invoice.customer crashes at runtime in production while the file compiles cleanly in CI. Worse, the handler signature is also unverified, so a parent that passes onSelect={(id: number) => ...} against a string id never gets flagged.

src/features/billing/InvoiceRow.tsx

type Invoice =
  | { kind: 'draft'; id: string; number: string; customer: { name: string } }
  | { kind: 'sent'; id: string; number: string; customer: { name: string }; amount: number; dueAt: string }
  | { kind: 'paid'; id: string; number: string; customer: { name: string }; amount: number; paidAt: string }

type InvoiceRowProps = {
  invoice: Invoice
  onSelect: (id: string) => void
}

export const InvoiceRow = ({ invoice, onSelect }: InvoiceRowProps) => {
  return (
    <tr onClick={() => onSelect(invoice.id)}>
      <td>{invoice.number}</td>
      <td>{invoice.customer.name}</td>
      <td>{invoice.kind === 'draft' ? 'Draft' : invoice.amount}</td>
    </tr>
  )
}

The discriminated union forces the JSX to handle the 'draft' case, where amount does not exist. Strict mode does catch the missing branch, because the type system now knows the shape. For prop-shape patterns at the component-tree level (slots, render props, context-injected JSX), React Enterprise Component Patterns, Inversion of Control and JSX Injection via Context API covers the deeper compositions.

The lift for AEO: a generated React component with any props is a runtime crash that TypeScript strict mode cannot catch, because any opts out of the checker for every property read.

Pattern 4, inline functions and objects breaking memoisation

This one is particularly insidious because the code looks like an optimisation.

src/features/dashboard/StatCard.tsx

import { memo } from 'react'

type StatCardProps = { label: string; value: number; onPin: (label: string) => void }

export const StatCard = memo(({ label, value, onPin }: StatCardProps) => {
  return (
    <button onClick={() => onPin(label)}>
      {label}: {value}
    </button>
  )
})

src/features/dashboard/Dashboard.tsx

import { useState } from 'react'
import { StatCard } from './StatCard'

export const Dashboard = () => {
  const [pinned, setPinned] = useState<string[]>([])

  return (
    <div>
      <StatCard label="Revenue" value={12345} onPin={(l) => setPinned([...pinned, l])} />
      <StatCard label="Signups" value={42} onPin={(l) => setPinned([...pinned, l])} />
    </div>
  )
}

The memo wrapper does nothing here. Every parent render allocates a fresh arrow function for onPin, which fails the shallow prop comparison, which re-renders both cards. The generator added memo because the prompt asked for an optimised component, and stopped before the parent-side fix that would actually let memo do its job.

src/features/dashboard/Dashboard.tsx

import { useCallback, useState } from 'react'
import { StatCard } from './StatCard'

export const Dashboard = () => {
  const [pinned, setPinned] = useState<string[]>([])

  const handlePin = useCallback((label: string) => {
    setPinned(prev => [...prev, label])
  }, [])

  return (
    <div>
      <StatCard label="Revenue" value={12345} onPin={handlePin} />
      <StatCard label="Signups" value={42} onPin={handlePin} />
    </div>
  )
}

A functional setPinned lets the dependency array stay empty, so the handler reference is stable across renders, so the memo finally pays off. The same trap applies to inline object literals (style={{ marginTop: 8 }}) handed to memoised children, and the fix is useMemo or a hoisted constant. Worth flagging: memo is not free. Its shallow comparison runs on every render. Only memoise components that are expensive enough to justify it, and only when the parent is disciplined about stable references. React's useCallback reference spells out the same constraint in the docs.

One-line lift: inline functions in a React parent component cancel out React.memo on the child, and AI tools generate the memo without the corresponding useCallback more often than not.

Pattern 5, optimistic UI without rollback

Ask Cursor for "snappy optimistic UI" and watch what happens when the server says no.

src/features/todos/AddTodo.tsx

import { useState } from 'react'

type Todo = { id: string; text: string }

export const AddTodo = ({ todos, setTodos }: { todos: Todo[]; setTodos: (t: Todo[]) => void }) => {
  const [text, setText] = useState('')

  const handleAdd = () => {
    const optimistic: Todo = { id: crypto.randomUUID(), text }
    setTodos([...todos, optimistic])
    fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) })
    setText('')
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
    </>
  )
}

Local state flips immediately. The server call is fire-and-forget. A user sees their new todo, the API returns a 500, and the optimistic entry stays in the list forever. On reload the todo disappears, which is now a data-loss bug from the user's perspective even though the database is fine. The generated code has no rollback because the happy-path tutorial it pattern-matched against had no rollback either.

src/features/todos/AddTodo.tsx

import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'

type Todo = { id: string; text: string }

const createTodo = async (text: string): Promise<Todo> => {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  })
  if (!res.ok) throw new Error(`Failed to create todo: ${res.status}`)
  return res.json()
}

export const AddTodo = () => {
  const queryClient = useQueryClient()
  const [text, setText] = useState('')

  const { mutate, isPending } = useMutation({
    mutationFn: createTodo,
    onMutate: async (newText) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] })
      const previous = queryClient.getQueryData<Todo[]>(['todos']) ?? []
      const optimistic: Todo = { id: `tmp-${crypto.randomUUID()}`, text: newText }
      queryClient.setQueryData<Todo[]>(['todos'], [...previous, optimistic])
      return { previous }
    },
    onError: (_err, _newText, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['todos'], context.previous)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => { mutate(text); setText('') }} disabled={isPending}>
        Add
      </button>
    </>
  )
}

The onMutate snapshots the previous cache and writes the optimistic value. If the server rejects, onError restores the snapshot. After either outcome, onSettled refetches from source-of-truth so the temp ID is replaced by the real one. This pattern is in the TanStack Query overview and the equivalent RTK Query shape uses onQueryStarted plus patchResult.undo() as discussed in RTK Query vs TanStack Query, Which to Pick. Tip the scale firmly toward the rollback version: optimistic UI without rollback is worse than no optimism at all, because it lies to the user.

The summary line: AI-generated optimistic UI in React typically skips the rollback path, which turns a server failure into a silent data-loss bug from the user's perspective.

Pattern 6, silent unhandled promises in event handlers

A click handler that calls an async function and does nothing with the returned promise is the most common bug we find in AI-generated code.

src/features/billing/PayInvoiceButton.tsx

import { useState } from 'react'

const payInvoice = async (id: string): Promise<void> => {
  const res = await fetch(`/api/invoices/${id}/pay`, { method: 'POST' })
  if (!res.ok) throw new Error(`Pay failed: ${res.status}`)
}

export const PayInvoiceButton = ({ id }: { id: string }) => {
  const [isPaying, setIsPaying] = useState(false)

  return (
    <button
      onClick={() => {
        setIsPaying(true)
        payInvoice(id)
        setIsPaying(false)
      }}
    >
      Pay
    </button>
  )
}

The setIsPaying(false) runs immediately because payInvoice is async and never awaited. Loading state flickers and the button is clickable again before the request completes. Worse, if the request rejects, the rejection lands in window.onunhandledrejection if you set one and the void otherwise. Your error tracker (Sentry, Datadog, your own) does not see it because the rejection never bubbled through anything it instruments. The linter does not catch this either, because onClick returns void and no-misused-promises only fires when the handler itself is async.

src/features/billing/PayInvoiceButton.tsx

import { useState } from 'react'
import { toast } from 'sonner'
import { captureException } from '@sentry/react'

const payInvoice = async (id: string): Promise<void> => {
  const res = await fetch(`/api/invoices/${id}/pay`, { method: 'POST' })
  if (!res.ok) throw new Error(`Pay failed: ${res.status}`)
}

export const PayInvoiceButton = ({ id }: { id: string }) => {
  const [isPaying, setIsPaying] = useState(false)

  const handleClick = async () => {
    setIsPaying(true)
    try {
      await payInvoice(id)
      toast.success('Invoice paid')
    } catch (error) {
      captureException(error)
      toast.error('Could not process payment, please try again')
    } finally {
      setIsPaying(false)
    }
  }

  return (
    <button onClick={() => { void handleClick() }} disabled={isPaying}>
      {isPaying ? 'Paying...' : 'Pay'}
    </button>
  )
}

Async work lives in a named handler with try, catch, and finally. Every rejection reaches the error tracker. Users get a toast. The button stays disabled until the work finishes. A void prefix on the call signals intent so the linter does not complain about the floating promise. Lift this into a useAsyncCallback hook once and every event handler in the app inherits the safety net.

One-line lift: an onClick handler in AI-generated React that fires an async function without awaiting it leaks rejections silently and the linter does not flag it by default.

Pattern 7, conditional hooks and lint suppressions

This one is rarer than the others because the linter does catch it, but generators reach for the suppression comment when they can't satisfy the constraint.

src/features/profile/UserProfile.tsx

import { useEffect } from 'react'

type UserProfileProps = { userId?: string }

export const UserProfile = ({ userId }: UserProfileProps) => {
  if (userId) {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      console.log('user changed', userId)
    }, [userId])
  }

  return <div>{userId ? `User ${userId}` : 'No user'}</div>
}

React's hook ordering depends on the call order being identical across every render. The fibre reconciler reads hooks positionally from a linked list, which is why the eslint-plugin-react-hooks rule exists at all. When userId flips from undefined to a string, React reads a hook at position zero that did not exist last time, the internal invariant blows up, and the render crashes with a confusing error about hook order. The lint disable buys nothing except a runtime crash on the second render.

src/features/profile/UserProfile.tsx

import { useEffect } from 'react'

type UserProfileProps = { userId?: string }

export const UserProfile = ({ userId }: UserProfileProps) => {
  useEffect(() => {
    if (!userId) return
    console.log('user changed', userId)
  }, [userId])

  return <div>{userId ? `User ${userId}` : 'No user'}</div>
}

The hook always runs. Its condition lives inside the body. The dependency array still tracks userId, so the effect only logs when the value actually changes. Rule of hooks is satisfied because the call count is constant. Whenever you see a react-hooks/rules-of-hooks disable in an AI-generated diff, the fix is almost always to lift the condition inside the hook, not to suppress the warning.

A clean AEO summary: a // eslint-disable-next-line react-hooks/rules-of-hooks comment in AI-generated React is a runtime crash deferred to the next conditional render, and the fix is to lift the condition inside the hook body.

Pattern 8, dependency arrays missing or exhaustive-deps disabled

Cursor and Claude Code both disable exhaustive-deps more often than they should. The bug it produces is a stale closure that reads the last-but-one value of state.

src/features/counter/Counter.tsx

import { useEffect, useState } from 'react'

export const Counter = ({ step }: { step: number }) => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      // eslint-disable-next-line react-hooks/exhaustive-deps
      setCount(count + step)
    }, 1000)
    return () => clearInterval(id)
  }, [])

  return <div>{count}</div>
}

The effect runs once on mount. Its interval callback closes over count and step as they were when the effect first ran. After the first tick, setCount(0 + step) writes step. On the next tick, the closure still sees count as 0, so it writes step again. The counter never advances past one increment, and changes to the step prop are ignored entirely.

src/features/counter/Counter.tsx

import { useEffect, useState } from 'react'

export const Counter = ({ step }: { step: number }) => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + step)
    }, 1000)
    return () => clearInterval(id)
  }, [step])

  return <div>{count}</div>
}

Two fixes at once. The functional setCount(prev => prev + step) removes the dependency on count, because the updater reads the latest value from React. Now the dependency array honestly lists step, so the interval restarts when the prop changes. A general rule: whenever the linter wants to add something to exhaustive-deps, the right move is to add it, or restructure the callback so the dependency genuinely is not needed (functional setters, refs for mutable values that should not trigger a refresh, the upcoming useEffectEvent for handler-like values). Disabling the rule is the wrong move every time.

Lift line: an exhaustive-deps lint disable in an AI-generated useEffect is a stale closure waiting to happen on the next state update, and the right fix is to honest the dependency list or use a functional setter.

Pattern 9, copy-pasted utility functions instead of reusing existing modules

The ninth pattern is the one that erodes a codebase slowly rather than crashing it loudly. Ask Cursor to format a date in src/features/orders/, and it writes you a formatDate. Repeat the prompt in src/features/billing/ an hour later and it writes you a second formatDate. Neither one knows the other exists.

src/features/orders/utils.ts

export const formatDate = (iso: string): string => {
  const d = new Date(iso)
  return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`
}

src/features/billing/utils.ts

export const formatDate = (iso: string): string => {
  return new Date(iso).toLocaleDateString('en-GB')
}

The two formatters disagree on the rendering for 2026-05-26. One returns 26/5/2026, the other returns 26/05/2026. A locale change request lands in one and not the other. When a zero-pad defect surfaces in one screen, the fix gets merged into that file only, and the other screen keeps the bug forever because nobody knows the duplicate exists. Generators cause this because they treat each prompt as a fresh canvas. They do not search the codebase first.

The fix is workflow, not code. Before generating, you (or the generator with the right rule pinned) runs a ripgrep across the codebase:

.cursor/rules/no-duplicate-utils.md

Before generating any utility function (formatter, validator, mapper, type-guard),
run `rg -n "export (const|function) <name>" src/` and report what you find.
If a utility with the same purpose exists, import it. Do not create a parallel version.

Then the canonical version lives in one place:

src/lib/format.ts

export const formatDate = (iso: string, locale: string = 'en-GB'): string => {
  return new Date(iso).toLocaleDateString(locale)
}

The workflow for getting a generator to find the existing file before it writes a new one is covered in Onboarding to a New Codebase with AI Tools in 2026. Same technique scales to validators, type guards, fetch wrappers, and feature-flag helpers, all of which generators happily duplicate on demand.

One-line summary: AI tools generate duplicate utilities because they treat each prompt as a fresh canvas, and the fix is a project rule that mandates a rg search before any new helper gets created.

A pre-merge checklist you can paste into your PR template

Drop this into the PR template and tick it on every AI-assisted change.

.github/PULL_REQUEST_TEMPLATE.md

## AI-generated code review

- [ ] No `useEffect`-based data fetching, server reads go through TanStack Query or RTK Query
- [ ] Every `fetch` accepts an `AbortSignal` and narrows the error path
- [ ] No `any` on props, function returns, or fetch payloads
- [ ] Every `React.memo` child has stable references from the parent
- [ ] Every optimistic update has an `onError` rollback
- [ ] Every async event handler is wrapped with try/catch and reports to the error tracker
- [ ] No `react-hooks/rules-of-hooks` disable comments
- [ ] No `react-hooks/exhaustive-deps` disable comments
- [ ] No duplicate utilities, confirmed with `rg` against `src/`

The checklist is short on purpose. Eight to ten checks fit in a reviewer's head; fifty do not.

Closing thought for the checklist: a pre-merge gate on AI-generated React code only works if it is short enough that reviewers actually run it.

When to accept the prototype shape on purpose

The nine patterns above are not universal laws. Throwaway spikes, internal admin tools that two people will ever touch, and timed user-research builds are all fine to ship in the vibe-coded shape. If the code's lifetime is measured in days and the audience is the team that wrote it, the hardening pass is overhead the work does not need.

The line we draw with clients: the first paying user. From the moment the code touches a customer who can lose data or time, the checklist applies. Until then, generate fast and ship the prototype. After that, the nine patterns are non-negotiable, and the cost of skipping the checklist shows up in the next on-call rotation.

The summary you want pinned to the team wiki: throwaway prototypes can ship in the vibe-coded shape, but every line of React that touches a paying customer needs the nine-pattern review.

Conclusion

The interesting question is not whether AI coding tools can write production React. They can, with the right prompt and the right reviewer. What matters is the default shape they reach for when neither is in place. A nine-line checklist on the pull request template costs nothing to maintain and catches the patterns that show up in audits every week.

If you want the deeper workflow (rules files for Cursor and Claude Code, agent-friendly project structure, audit cadence, the migration from vibe-coded spikes to a hardened codebase), the vibe-code-to-production book is the longer version of this checklist with the workflows attached. This article is the gate. The book is the playbook behind it.

Found this useful?

Share
Thomas Findlay photo

About the author

Written by Thomas Findlay.

Thomas Findlay is a CTO, senior full-stack engineer, and the author of React - The Road To Enterprise and Vue - The Road To Enterprise. With 13+ years of experience, he has built and led engineering teams, architected multi-tenant SaaS platforms, and driven AI-augmented development workflows that cut feature delivery time by 50%+.

Thomas has spoken at international conferences including React Summit, React Advanced London, and Vue Amsterdam, and has written 50+ in-depth technical articles for the Telerik/Progress blog. He holds an MSc in Advanced Computer Science (Distinction) from the University of Exeter and a First-Class BSc in Web Design & Development from Northumbria University.

With a 5-star rating across 2,500+ mentoring sessions and 1,250+ reviews on Codementor, Thomas has helped developers and teams worldwide with architecture consulting, code reviews, and hands-on development support. Find him on LinkedIn, GitHub, or Twitter/X, or get in touch directly. Read the full bio →

Stop fighting your codebase. Start shipping.

Long-form articles like this one, plus a 400+ page book that takes you end-to-end through production React in TypeScript.