Engineering

Why AI-generated React breaks

28 failure patterns, catalogued

AI-generated React usually compiles. TypeScript is usually satisfied. The failure arrives later, in the browser or in the Worker, at the moment a real person loads the page. This is the catalogue of how that happens, taken directly from the static gate Fabricate runs on every generated phase before it deploys.

Author
By Fabricate Team
Last updated
Updated July 26, 2026
Reading time
20 min read
Key takeaways
  • AI-generated React fails at runtime rather than at compile time, which is why "it built successfully" tells you almost nothing about whether the app works.
  • The failures are systematic, not random: they track the shape of the training data, which skews toward older React idioms, tutorial snippets without surrounding context, Express-shaped backends, and Tailwind v3.
  • Fabricate encodes 28 named rules in worker/agents/validators/preDeploySafetyGate.ts, 21 at error severity and 7 at warning severity.
  • Twelve deterministic transforms repair eleven of those failure shapes with no model in the loop, before the gate runs.
  • Only two rules abort a deploy outright: syntax_parse_failure and missing_import_target. Everything else is logged and shipped, because a blocked deploy leaves the user staring at an unchanged template.
  • A static gate catches shapes, not semantics. It cannot tell you your product logic is wrong, and it deliberately skips aliased import paths.
Table of Contents

The short answer

AI-generated React breaks because language models are optimised to produce code that resembles their training data, and the resulting code is syntactically valid, type-plausible and runtime-wrong. The compiler has no opinion about a useEffect that updates the state it depends on. TypeScript has no opinion about a Drizzle insert naming a column your schema never declared. Both are accepted at build time and both fail the first time a person opens the app.

That gap is the whole problem. "The build succeeded" is a much weaker signal for generated code than for hand-written code, because a human who wrote the effect knew what they meant by it and a model did not. The useful question is not whether the code compiles but whether it belongs to one of the known shapes that compile and then fail.

Fabricate answers that question with a static gate that runs on every phase of generation, before the files are persisted and before anything is deployed. It lives in worker/agents/validators/preDeploySafetyGate.ts, it is roughly 3,900 lines, and it currently encodes 28 named rules: 21 classed as errors and 7 as warnings. This post is the catalogue, grouped into families, with the underlying reason each family exists.

These failures are not random

The single most useful thing to understand about AI-generated code defects is that they are not distributed like human bugs. Humans produce idiosyncratic mistakes: a typo here, a mishandled edge case there. Models produce the same handful of mistakes over and over, because those mistakes are the statistical centre of gravity of the corpus they learned from.

Four skews explain most of what the gate catches. React idiom age: a great deal of public React predates hooks-era dependency discipline, and effect examples routinely omit the dependency array because the post was about something else. Snippet decontextualisation: tutorial code is written to be short, so a selector returning a fresh object literal looks elegant in three lines and loops forever in a real component tree. Backend shape: the overwhelming majority of Node examples are Express, so models reach for a request-time handler that builds the app and registers routes inside fetch, which is exactly what breaks route matching on Cloudflare Workers. Version lag: Tailwind v3 dominates the corpus, v4 changed how the cascade works, and models mix the two.

Every rule below can be read as a fossil of one of those four skews. That is also why a static gate works at all. If the defects were random you could not enumerate them; because they are systematic, twenty-eight patterns cover a large share of what goes wrong.

The 28 rules sort into six families. The grouping is ours, not a structure imposed by the code, but every rule name used in this post is verbatim from the PreDeploySafetyRule union in worker/agents/validators/preDeploySafetyGate.ts.

Note

The gate runs inside the generation pipeline, not as a separate lint step you have to remember to run. Fabricate uses a phasic pipeline (worker/agents/execution/phasicStateMachine.ts) that plans a milestone, implements it, deploys, validates, and repeats. The safety gate is invoked on the files produced by each phase before they are saved.

FamilyRulesSeverity mixWhat the user sees when it ships
Render loops43 error, 1 warningFrozen tab, "Maximum update depth exceeded", fan noise
Empty first render31 error, 2 warningWhite screen on load, or animations that silently do nothing
Worker and route wiring108 error, 2 warning404 on every page, HTML where JSON was expected, 401 on signup
Database and schema54 error, 1 warningOpaque 500 on the first write, "no such column", "no such table"
Version skew22 errorEvery spacing utility silently ignored, or the dev server refusing to start
Escape hatches and placeholders43 error, 1 warningBuild failure, or a binding that is undefined at runtime

The 28 pre-deploy safety rules grouped by failure family

Family 1: render loops and unstable references

This family is four rules, and it is the reason the gate exists at all. A render loop is uniquely bad in a generated app because it does not look like a bug in the preview logs. The page simply never settles, the tab heats up, and the person who typed a prompt thirty seconds ago concludes the tool does not work.

The rule effect_missing_dependency_array fires when an effect body calls a setter from a useState pair and the effect has no dependency array at all. That is an unconditional re-render on every commit. Its warning-severity sibling, effect_self_dependency_state_update, fires on the subtler version: the effect lists a state variable in its dependencies and also calls that variable's setter, which loops unless a guard breaks the cycle. It is a warning precisely because a guard is a legitimate pattern and the gate cannot always prove one is absent.

The rule top_level_state_update_in_render catches a setter called directly in the component body rather than inside an effect, handler or callback. React re-renders, the setter runs again, and the loop is immediate. The fourth rule, unstable_store_selector, matches six distinct shapes: selectors returning an object literal, selectors returning an array literal, selectors calling map, filter, reduce, sort, slice, concat or flatMap on the way out, selectors calling Object.keys, values or entries, identity selectors of the form s => s, and store hooks called with no selector at all. All six produce a new reference every render, and a new reference means the store believes the slice changed.

Unstable selector plus dependency-free effect
// What the model emits. Compiles. Locks the tab.
const { items, total } = useCartStore((s) => ({
  items: s.items,
  total: s.total,
}));

const [filtered, setFiltered] = useState([]);
useEffect(() => {
  setFiltered(items.filter((i) => i.active));
});

// What it should be. One hook per value, derived state derived.
const items = useCartStore((s) => s.items);
const total = useCartStore((s) => s.total);

const filtered = useMemo(
  () => items.filter((i) => i.active),
  [items],
);
The object literal in the selector allocates a fresh object on every render, so the store never sees a stable slice. The effect has no dependency array, so it runs after every commit and sets state, which forces another commit. Either alone is enough to loop; together they are the single most recognisable signature of AI-generated React.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Family 2: code that only breaks on the empty first render

Three rules cover defects that are invisible while you are looking at the code and obvious the moment the app loads with no data in it. This matters more for generated apps than for hand-written ones, because a generated app is almost always first observed in exactly that state: freshly deployed, database empty, no rows anywhere.

The rule reduce_without_initial_value flags any call to reduce or reduceRight with fewer than two arguments. JavaScript throws "Reduce of empty array with no initial value" when the array is empty, and an empty array is the normal state of a list on first paint. The model wrote the reduce while thinking about the populated case, which is the case it saw in training. The fix is a single character of diligence: pass a seed, as in reduce((sum, item) => sum + item.price, 0).

The rule module_level_jsx flags a JSX element assigned to a variable at module scope rather than created inside a component. The element is shared across every render and every instance, producing stale content and retaining references longer than intended. The detector allows the known-safe exceptions: router factories such as createBrowserRouter, and the root element in src/main.tsx or src/entry-client.tsx when it is passed straight to a render or hydrateRoot call. The rule framer_motion_props_on_non_motion_element is the quietest failure in the catalogue: animation props such as whileHover, whileTap, animate or initial placed on a plain div instead of a motion.div. Nothing errors. Nothing warns. The animation simply never happens, and the person who asked for a hover effect assumes the tool ignored them.

Family 3: Worker entry and route wiring

This is the largest family, ten rules, and it exists almost entirely because models were trained on Express. Fabricate generates apps onto Cloudflare Workers with Hono, and the Express muscle memory is wrong in ways that are specific and repeatable.

Four rules cover the entrypoint. missing_worker_entry_for_api_routes fires when the app calls /api endpoints or defines them in a worker file but worker/index.ts does not exist. missing_vite_config_for_backend_runtime fires when there is backend code but no root Vite config, or the config omits or disables the Cloudflare plugin, in which case same-origin /api requests never reach the Worker runtime and fall through to the frontend dev server. worker_routes_not_mounted fires when worker/userRoutes.ts defines /api routes but worker/index.ts never calls userRoutes(app) at module scope before the export. invalid_worker_entry_fetch_export fires when the entry wraps app.fetch in a custom fetch method, the direct Express translation, which breaks Hono route matching because routes end up registered per request.

Three more cover routes in the wrong place. top_level_worker_route_registration catches registrations that appear after the userRoutes function instead of inside it, so they are never mounted. worker_app_stub_leak catches an unresolved placeholder declaration of the form const app = { _stub: true }, which redeclares app and crashes the preview Worker. missing_spa_fallback_delegation catches a wrangler config declaring an ASSETS binding while the Worker never delegates non-API requests to env.ASSETS.fetch, so the root path and every client-side route return 404 even though the Worker is healthy for /api calls. That one is the difference between "preview not available" and a working app.

The final three concern the API surface. missing_api_catchall warns when /api routes exist with no catch-all, so unmatched API requests return HTML to a caller expecting JSON. hono_missing_appenv warns when a Hono instance is constructed without the AppEnv generic, which is how bindings lose their types. public_auth_route_protected has the sharpest user impact: it fires when /api/auth/register or /api/auth/login end up behind auth middleware, inline or under a dominating app.use mount. Every signup returns 401 and the app is unusable by anyone without an account.

Express-shaped Worker entry versus the shape that works
// What the model emits: routes registered per-request.
export default {
  async fetch(request, env, ctx) {
    const app = new Hono();
    app.get('/api/items', (c) => c.json([]));
    return app.fetch(request, env, ctx);
  },
};

// worker/index.ts as it needs to be.
const app = new Hono<AppEnv>();
userRoutes(app);

app.notFound((c) =>
  c.req.path.startsWith('/api/')
    ? c.json({ success: false, error: 'Not found' }, 404)
    : c.env.ASSETS.fetch(c.req.raw),
);

export default { fetch: app.fetch } satisfies ExportedHandler<Env>;
The first form is a faithful translation of an Express app and it fails three rules at once: invalid_worker_entry_fetch_export, worker_routes_not_mounted and missing_spa_fallback_delegation. The second form registers routes once at module scope, returns JSON for unmatched API paths, and hands everything else to the static asset binding so client-side routing works.

Family 4: database, schema and SQL

Five rules cover the gap between what the generated backend believes about the database and what the database actually contains. This family is dangerous because the failures are late: the app looks fine until the first write, and then returns a 500 with no useful detail.

The rule schema_column_drift is the most interesting analysis in the file. It builds a map of every table and its declared columns from every sqliteTable call in the project, then walks the AST of every other Worker file and flags references to columns that do not exist, including the keys of db.insert(...).values({...}) and db.update(...).set({...}). Drizzle's inferred types are permissive enough that TypeScript accepts the invented column; D1 throws "no such column" the first time the route runs.

The rule empty_drizzle_config fires when the schema declares tables but drizzle.config.ts is missing or empty, so drizzle-kit never generates a migration and the table never reaches D1. That failure is confusing because template-seeded tables already have migrations, leaving the app half alive: authentication works, the feature you asked for 500s. unresolved_d1_binding fires when Worker code references env.DB but the wrangler config has no d1_databases section, no database_id for the DB binding, or an id that is not a valid UUID. sql_insert_missing_values_keyword catches generated SQL with a column list followed by a tuple and no VALUES keyword between them, which SQLite reports as the unhelpful 'near "(": syntax error'. And handler_db_call_unguarded warns when a route handler awaits a query with no try/catch, because the thrown error falls through to Hono's default error handler and becomes an opaque 500 nobody can debug from a preview window.

Schema column drift: compiles, then throws at runtime
// worker/database/schema.ts
export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull(),
});

// worker/userRoutes.ts — TypeScript is satisfied.
// D1 throws "no such column: user_agent" on the first login.
await db.insert(sessions).values({
  id: crypto.randomUUID(),
  userId: user.id,
  userAgent: c.req.header('user-agent'),
  revokedAt: null,
});
The model added session hardening it had seen elsewhere without adding the columns to the schema. schema_column_drift resolves the imported table binding, compares the value keys against the declared column set, and names both the missing column and the schema file it belongs in.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Family 5: version skew

Two rules, and they are the clearest illustration in the whole catalogue of the training-data argument. Tailwind CSS v4 changed how utilities interact with the cascade, and the corpus is still overwhelmingly v3.

The rule tailwind_v4_layer_conflict fires when a project uses Tailwind v4, detected through the browser build in an HTML file or an @import "tailwindcss" in CSS, and any stylesheet or inline style block also contains a universal reset of the form * { margin: 0; padding: 0 }. In v4 utilities live in a CSS layer, and unlayered styles always win against layered ones. The reset silently defeats every spacing utility in the project, with no error anywhere. The layout looks wrong and the cause is one line the model added out of habit, because that reset appears in a decade of CSS boilerplate.

The rule tailwind_v4_vite_plugin_with_v3 catches the mirror image: a vite config that imports @tailwindcss/vite, which is the v4 plugin, in a project that is demonstrably v3 because it has a tailwind.config file, a postcss.config file, or @tailwind base/components/utilities directives in its CSS. That combination crashes the Vite dev server on startup. The model mixed two major versions because both are well represented in what it learned from, and nothing in either snippet signals which era it belongs to.

Important

Version skew is the failure category most likely to grow rather than shrink over time. Every framework major release adds a new pair of incompatible idioms to the corpus, and models keep both. It is also the category least visible to a build step, because both versions are individually valid.

Family 6: escape hatches and placeholders

The last four rules cover code that never resolves and code that suppresses the signal telling you it never resolved.

The rule syntax_parse_failure runs every source file through the Babel parser with the JSX and TypeScript plugins and error recovery switched off. A file that does not parse cannot deploy, and this is one of only two rules that abort the deploy outright. It also short-circuits every check that runs after it for that file, so a single unbalanced brace does not produce a cascade of misleading secondary findings. The rule missing_import_target resolves every relative import against the actual deployment file set and reports the ones that point at nothing, which is how a model referencing a component it planned but never wrote gets caught.

The rule ts_ignore_directive warns on every @ts-ignore comment. This is about signal preservation rather than an immediate crash: @ts-ignore suppresses whatever error lands on the next line, including errors that appear later for unrelated reasons, so it hides future problems as well as the present one. @ts-expect-error is strictly better because it fails once the error it was suppressing goes away. The rule unresolved_resource_placeholder scans wrangler config for double-brace template tokens never substituted with a real resource id, which is a straightforward runtime bomb: the binding arrives undefined and the Worker 500s the first time anything touches it.

What actually happens when a rule fires

This is where an honest answer diverges from the marketing one. The word "gate" implies a hard stop, and for most of these rules that is not what happens.

The first thing the pipeline does is repair what it can with no model involved. Twelve deterministic transforms run before the check: splitting an unstable store selector into one hook call per value, rewriting @ts-ignore to @ts-expect-error, deleting a leaked worker app stub, moving top-level Worker routes back inside userRoutes, generating a missing Worker entrypoint, generating a missing Vite config, normalising a Vite config that omits the Cloudflare plugin or the React dedupe hardening, normalising an incorrect Worker export, mounting userRoutes when it was never mounted, stripping the v4 Tailwind Vite plugin from a v3 project, generating a canonical drizzle.config.ts when the schema declares tables and the config is empty, and inserting the missing VALUES keyword into generated SQL. Those twelve transforms cover eleven of the twenty-eight rules, because two of them address the same Vite config rule from different starting states. They are pure text transforms with deterministic output. They run first, the gate runs against the result, and the rewritten contents are persisted back to the file manager.

Exactly two rules abort the deploy: syntax_parse_failure and missing_import_target. Those throw a typed error that routes into an auto-repair pass, because code that does not parse or imports files that do not exist cannot usefully run. Every other error-severity finding is logged with its file, line and rule name. In the phasic pipeline those remaining findings get one model repair attempt, the gate is re-run against the repaired files, and the deploy then proceeds whether or not the findings cleared.

That is a deliberate trade, and it was not always the design. Blocking on every error meant a phase could fail outright and leave the preview showing the unchanged template, which is worse than a deployed app with a known flaw: the user can at least see, click and describe something that rendered. Warnings never block, and several exist mainly to be fed back to the model on the next turn as structured, self-correctable context.

Tip

If you are building your own generation pipeline, the deterministic-repair-before-check ordering is the part worth stealing. A transform that always produces the same output for the same input costs nothing, cannot hallucinate, and removes an entire class of finding before a model is ever asked to fix anything.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

What a static gate cannot catch

A pattern catalogue catches patterns. It says nothing about whether the app is correct, and it is worth being precise about the boundaries.

It does not know what you asked for. Nothing in these 28 rules can tell that the checkout flow charges the wrong amount, that the filter returns the complement of what the user meant, or that two features contradict each other. Semantic correctness is entirely outside its reach, and that is the largest category of "the AI built the wrong thing" complaints.

It does not type-check. The gate parses; it does not run tsc, so a genuine type error outside the named shapes passes straight through. It deliberately skips aliased import paths: only relative specifiers beginning with ./ or ../ are resolved, because @/ and ~ prefixes are the bundler's business and treating them as unresolved would produce constant false positives. Several detectors are regex-based rather than AST-based, most notably the store-selector rules, which key on hook names matching a use*Store shape. Rename the hook outside that convention and the detector will not see it.

Coverage is scoped by file type: most rules only inspect .ts, .tsx, .js and .jsx, with narrower rules for .sql files, wrangler config and CSS or HTML. The deepest limit is structural. Every rule is a shape someone recognised after watching it fail, so a novel failure has no rule until it has happened enough times to be named. The catalogue grows because production keeps teaching it, which is an honest description of how it reached 28 and a reason to expect the number to keep moving.

What to check in your own AI-generated code

Most of this transfers to any tool. If you are reviewing AI-generated React from any source, these are the highest-yield things to look at, roughly in order of how often they are the actual cause of a broken app.

Read every useEffect first. Confirm it has a dependency array, and that it does not both depend on a state value and call that value's setter without a guard. Then check for setter calls sitting directly in the component body. Those two checks cover most frozen-tab reports.

Check every store selector for a fresh reference. If the selector returns an object literal, an array literal, or the result of map, filter, sort or Object.keys, split it into one hook call per primitive value and derive the rest with useMemo. Check every reduce for a second argument. Then load the app with no data at all, because empty-state crashes are the ones that never surface while you are clicking around a seeded database.

On the backend, confirm the Worker entry registers routes once at module scope rather than inside the request handler, that non-API requests are delegated to the static asset binding, and that signup and login are not sitting behind the auth middleware. Cross-check every column your queries reference against the schema file, because that mismatch will not fail until the first write. Finally, grep for @ts-ignore and for unsubstituted placeholder tokens in your wrangler config, and treat both as unfinished work rather than as decisions.

Ready to Build?

Start building your full-stack application with Fabricate. Free tier available — no credit card required.

Start Building Free

Frequently Asked Questions

Why does AI-generated React code compile but still break at runtime?
Because the compiler and the type checker only verify shape, not behaviour. A useEffect with no dependency array is valid TypeScript. A Drizzle insert naming a column your schema never declared satisfies Drizzle's inferred types. A store selector returning a new object every render is perfectly legal JavaScript. All three compile cleanly and all three fail the moment the app runs, which is why "the build succeeded" is a much weaker signal for generated code than for hand-written code.
What is the most common way AI-generated React breaks?
Infinite render loops, produced in one of four ways: an effect that updates state with no dependency array, an effect that depends on the state it updates without a guard, a state setter called directly in the component body, and a store selector that returns a new object or array reference on every render. Fabricate has a named rule for each of those four shapes, and the selector rule alone matches six distinct sub-patterns.
Why do language models write Express-style code for Cloudflare Workers?
Because Express dominates the Node.js examples in their training data. The direct translation is to construct the app and register routes inside the request handler, which on Cloudflare Workers with Hono means routes are registered per request and route matching breaks. The correct shape registers routes once at module scope and exports a plain fetch handler. Fabricate has four separate rules covering different versions of this mistake.
Does the pre-deploy safety gate block deployment when it finds problems?
Only for two rules: syntax_parse_failure and missing_import_target. Those abort the deploy and route into an auto-repair pass, because code that does not parse or that imports files which do not exist cannot run at all. Every other error-severity finding is logged with its file, line and rule name and gets one model repair attempt inside the phase, after which the deploy proceeds whether or not it cleared. Blocking on everything meant a failed phase left the preview showing an unchanged template, which is a worse outcome than a deployed app with a known flaw you can see and describe.
Can static analysis catch all AI code quality problems?
No, and it is important to be clear about the limits. A static gate catches recognised shapes. It cannot tell you the generated logic is wrong, that a checkout charges the wrong amount, or that two features contradict each other. It does not run a type checker. It deliberately skips aliased import paths because resolving them is the bundler's job. And every rule exists because a specific failure happened often enough to be named, so genuinely novel failures pass until they are catalogued.
Which models does Fabricate use to generate code?
Fabricate routes each step to a different model automatically, with no model picker. The live routing table in worker/agents/inferutils/config.ts sends architecture planning to Gemini 3.1 Pro Preview, code generation and both phase-planning and phase-implementation to Gemini 3.6 Flash, conversational responses and deep debugging to Grok 4.3, and cheap utility work such as template selection and context summarisation to Gemini 3.5 Flash Lite.
Why not just run a code fixer model over the output instead?
Fabricate tried that and turned it off. The two code fixers, realtimeCodeFixer and fastCodeFixer, are switched off in worker/agents/inferutils/config.ts by routing both actions to a DISABLED model rather than a real one, since the enablement gate reads the configured model name directly. The reason is cost in wall-clock time: a model pass over the generated files lengthens every build, and the phasic pipeline plus deterministic repair already covers most of what the fixers were catching. Twelve deterministic transforms repair eleven of the failure shapes in this catalogue with no model in the loop, which is faster, free, and cannot hallucinate a new bug into the file.
How much does it cost to generate an app on Fabricate?
Credits are metered on weighted tokens rather than on messages. One credit is 10,000 weighted tokens, where input counts 1.0, output 5.0, cache reads 0.1, and cache writes 1.25 or 2.0 depending on the cache lifetime, all normalised to a common baseline. The free tier includes 60 credits a month, a daily cap, and one published project; Pro is 25 dollars a month for 350 credits and Scale is 50 dollars a month for 700. Credits do not roll over.