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.
| Family | Rules | Severity mix | What the user sees when it ships |
|---|---|---|---|
| Render loops | 4 | 3 error, 1 warning | Frozen tab, "Maximum update depth exceeded", fan noise |
| Empty first render | 3 | 1 error, 2 warning | White screen on load, or animations that silently do nothing |
| Worker and route wiring | 10 | 8 error, 2 warning | 404 on every page, HTML where JSON was expected, 401 on signup |
| Database and schema | 5 | 4 error, 1 warning | Opaque 500 on the first write, "no such column", "no such table" |
| Version skew | 2 | 2 error | Every spacing utility silently ignored, or the dev server refusing to start |
| Escape hatches and placeholders | 4 | 3 error, 1 warning | Build 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.
// 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],
);Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building FreeFamily 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.
// 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>;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.
// 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,
});Ready to Build?
Start building your full-stack application with Fabricate. Free tier available — no credit card required.
Start Building FreeFamily 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 FreeWhat 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