How We Handle Errors in AI-Generated React Native Code
By Rishav
11th Jul 2026
Last updated: 11th Jul 2026
Ask any engineer who has shipped an LLM-driven code generator: the model isn't the hard part. Making the output survive is.
AI-generated code fails in ways human-written code doesn't. A stream can drop halfway through a JSX expression. A model can hallucinate a component name that isn't imported. A well-formed file can still throw at runtime because a nested TouchableOpacity expects an onPress handler that never got wired up. And the person watching all of this happen isn't a senior engineer with a stack trace at the ready — it's a founder, a designer, or a product manager who typed "make me a fitness app" and expected to see a screen.
At RapidNative, we've spent a lot of engineering effort on a single, unglamorous question: when the AI produces broken React Native code, how do we recover without the user ever having to think about it? This post walks through the actual architecture we've built — the JSX repair pass, the streaming resilience layer, the four-tier recovery cascade, the auto-fix prompt loop, and the observability we wrap around all of it.
Every file path, module name, and pattern in this post is real. It's a snapshot of how the system runs today.
Photo by Markus Spiske on Unsplash
The failure modes we plan for
Before you can design error handling, you have to be honest about the ways things break. In our production traffic, AI-generated React Native code fails along five distinct axes:
- Structural failures — unclosed JSX tags, unmatched braces, missing imports for a component the model just used. Usually caused by mid-stream token cutoffs.
- Stream failures — the SSE connection between the LLM and the browser drops because the user closed their laptop lid, switched networks, or hit a proxy timeout.
- Bundler failures — the code parses fine as text, but the runtime bundler (a Sucrase/Metro-flavored transformer in our case) rejects it because of an invalid TypeScript generic or an unknown module resolution.
- Runtime failures — the app renders, then throws inside a component:
undefined is not an object, unhandled promise rejection, invalid navigator config. - Semantic failures — the code technically runs, but produces a blank screen, an infinite loading spinner, or a "Not Found" route. Silent failures are the worst kind.
Each of these needs a different response. Wrapping everything in a single try/catch is exactly what you don't want — the LLM already does that, and it hides real bugs. The right pattern is layered defense, where each layer catches its own class of failure and hands the rest downstream.
Layer 1: Structural repair before the code ever runs
The first line of defense is a purpose-built JSX repair pass that runs on every AI-generated file before it hits the bundler. It lives at src/shared/utils/jsxFixer.ts — about 1,600 lines of code that exists for one reason: LLMs produce structurally-invalid JSX more often than anyone likes to admit, and Babel's parser is unforgiving.
The pass does three specific jobs:
Tag balancing. bestEffortCloseJSXTags(code) walks the source with a lightweight scanner that respects brace depth, string boundaries, comment blocks, and — critically — TypeScript generics. If it detects an unclosed <View> at the tail of a stream, it appends the missing </View> with correct indentation. If tags are already balanced, it returns the input unchanged (fast path).
Structural closing. closeStructuralElements(code) tracks open {}, (), and [] counts across the file. This matters because a stream that gets cut off mid-render leaves the JIT parser with return ( and nothing else. There's a special case here worth calling out: if we're closing an empty return(), we insert a null literal so the resulting expression is still valid.
Missing import repair. getMissingJSXImports(code) extracts every JSX component the code uses, compares that set against declared imports, and fixMissingJSXImports(code, missing) auto-injects the missing ones from an internal JSX_IMPORT_MAP. Same treatment for hooks — getMissingHookImports catches a useState call that never got its import.
The tricky part is not producing false positives. A TypeScript generic like useState<User>() looks like a JSX tag if you squint. So the pass includes isTypeScriptGeneric(code, position), which checks patterns like const Foo = <T,>, Array<T>, and generic arrow functions before deciding whether an angle bracket opens a component.
The philosophy: the model doesn't need to produce perfect syntax if we can make the code parseable ourselves. Every failure caught here is one that never reaches the user's preview.
Photo by Emile Perron on Unsplash
Layer 2: Streaming resilience
Everything the LLM produces is streamed to the user in real time — text, tool calls, code diffs, the whole conversation. And streams break constantly.
Our generation endpoint at src/app/api/user/ai/generate-v2/route.ts wraps every streaming iteration in a try/catch that specifically does not fail the request on stream error:
} catch (error) {
console.warn('⚠️ [Step 1] Stream error (continuing):', error.message);
emitSSE('error', { message: error.message, type: 'stream' });
}
The user's client sees an SSE event tagged stream and can decide what to do — usually retry. Meanwhile, the server continues processing whatever context it already gathered. Partial context is almost always better than no context.
The bigger recovery mechanism sits in src/lib/resilient-sse-instance.ts. We use persistent-request-response (backed by Vercel KV or Upstash Redis) to give every stream a streamId and buffer the emitted events for 600 seconds. When a client reconnects, it passes streamId and lastEventId, and the server replays every event the client missed. From the user's perspective, closing a laptop lid mid-generation is a non-event: they open it back up ten minutes later, and the response continues from where it stopped.
This is a pattern worth stealing regardless of your stack. A resumable stream turns a whole class of "AI code generator is broken" bug reports into invisible recoveries.
Layer 3: The four-tier runtime recovery cascade
Structural repair catches syntax errors. Streaming resilience catches connection errors. But the hardest class of failure is the code that parses cleanly, bundles successfully, and then explodes at runtime.
For those, we built the recovery cascade. It lives at src/modules/editor/client/timeline/RecoveryOrchestrator.ts — around 400 lines that replaced our earlier AutoRetryManager. The cascade has four layers, and each one escalates only if the previous one exhausts.
L1 — Silent auto-fix with AI
When a runtime error surfaces, the orchestrator waits a 2-second settling period (SETTLING_DELAY_MS) before doing anything. This is crucial. The bundler often reports several errors from a single code change in quick succession — think a bad import that causes three downstream errors. If you react to the first one, you'll fire three redundant fix attempts. The settling window lets the bundler stabilize so we see the real error set.
Then L1 picks the first eligible error, MD5-hashes it (so we can dedupe repeated attempts), and dispatches a silent fix request to the LLM. The user sees no message bubble, no toast, no indication anything went wrong. From their side, the app just... starts working. MAX_AUTO_RETRIES is 2 per unique error hash.
L2 — Iframe reload
If L1 fails, the next layer reloads the preview iframe without touching the code. This catches a real category of bug: the code is fine, but the bundler's incremental cache is in a bad state, or a hot-reload replaced a module in an inconsistent way. A full iframe reload rebuilds cleanly.
L3 — Full page reload with session persistence
If the iframe reload doesn't recover, we escalate to a full page reload — but not before persisting the generation state to sessionStorage under a prefix like rn_recovery_l3. We record the messageId, retryCount, and timestamp. On page load, the editor detects the persisted state and resumes from where it stopped, up to MAX_SESSION_RETRIES = 20 per session.
L4 — Surface to user
Only when all three automated layers exhaust does the error become visible to the user. Even then, we don't dump a stack trace — we render a friendly panel with a "Fix with AI" button and a "Restart project" option. The user has an actionable decision to make.
The whole cascade is wired into a small event bus. RecoveryOrchestrator subscribes to message_stream_started, message_stream_ended, build_error_received, and runtime_error_received. It queues errors during streaming and flushes them only after the stream ends. Reacting mid-stream would trigger recovery on transient errors that the still-generating code would have healed on its own.
Layered defense in depth — Photo by NASA on Unsplash
Layer 4: The auto-fix prompt loop (and how we prevent loops)
The L1 layer above hides a subtle design problem. When you feed an error back to an LLM and ask it to fix that error, there's a real chance the model produces a new version of the same error, or introduces a different one that then triggers another fix. Left unchecked, this becomes an infinite loop that burns credits and never converges.
Our fix-with-AI prompt is deliberately shaped so we can detect it later. It's built by buildFixWithAiPrompt in src/modules/editor/utils/buildFixWithAiPrompt.ts, and it always produces a string of the form:
There's an error in
[file paths]:``` [error message] ```Please fix this error.
That specific shape — starts with "There's an error in", ends with "Please fix this error." — is a signature. Downstream, the auto-retry logic checks whether the user's incoming prompt matches this signature. If it does, we know we're already inside a fix loop, and we suppress further silent auto-retries for the same error hash. The user can still manually click "Fix with AI" — but the automatic machinery backs off.
There's one more guard: L1 only fires during the initial screen generation. Once the user has entered normal chat mode and is iterating on code they've seen work, we don't want automated fix attempts firing behind the scenes while they're typing. The orchestrator checks userInitiatedCodeGenCount before dispatching.
The pattern behind all of this: loop-prevention is a first-class design concern, not something you add later. If your fix pathway can't detect that it's already been invoked, you'll ship a credit-burning bug into production.
Layer 5: Observability so we can debug what escapes
Everything above is what happens automatically. The last layer is what lets our team notice when the automatic stuff isn't enough.
Sentry captures every uncaught error, wired through src/app/global-error.tsx:
useEffect(() => {
Sentry.captureException(error);
}, [error]);
The instrumentation deliberately skips Sentry initialization in development (src/instrumentation.ts) — fast iteration wins over dev-time telemetry.
Slack alerts fire for high-signal events. notifySlackErrorAlert() sends a message with user email, projectId, timestamp, error message, and stack whenever an AI generation request fails outright. notifySlackBrokenScreen() fires when a post-generation check detects a blank screen or error route — with a 4-hour cooldown per project so a broken app doesn't spam the channel. That endpoint lives at src/app/api/user/ai/broken-screen-alert/route.ts.
Structured debug logs. In development or when AI_DEBUG_LOGGING=true, src/modules/api/services/ai/debug/logger.ts writes per-request log files under .ai-debug/. Each file captures init state (project, model, prompt, message history, tools), every tool call and its result, the AI response, and finish reason. When we're diagnosing a weird failure, we don't guess — we pull the request log.
Bundler error parsing. Runtime bundler errors come back in an ugly format — /app/index.tsx: Expected either /> or > (45:15). src/modules/file/bundler-error-parser.ts normalizes them into { file, line, column, message } so the frontend can render a clean error UI and the recovery orchestrator can dedupe by content, not by raw string.
Redux state for runtime errors. The editor's Redux slice at src/modules/editor/store/slices/editorSlice.ts keeps a runtimeErrors array of objects like { hash, error, paths, timestamp, source, errorType }. Everything downstream — the error panel, the "Fix with AI" button, the deduplication logic — reads from this single source of truth.
Together, this observability stack means we can answer three questions on any given failure: what happened, whose project, and did the automated recovery succeed. If all three come back with "we don't know," we've failed at the observability layer, and that's the bug we prioritize.
Photo by Carlos Muza on Unsplash
What we've learned along the way
A few patterns have crystallized after enough production traffic to trust them:
Repair beats regenerate. Every time we can fix output ourselves — closing a tag, adding an import, patching a generic — we save a full LLM round trip. LLM calls are expensive in dollars and slow in wall time. Deterministic fixes are neither.
The settling period is a load-bearing pattern. Reacting to the first error you see is almost always wrong when the underlying cause is going to produce three or four related errors in the next second.
Loop prevention is a design requirement. If your auto-fix mechanism can't recognize its own prompts, you have shipped a credit-burning bug into production. Signature-based detection is cheap and reliable.
Layer for orthogonal failure modes. A single retry loop can't handle syntax errors, streaming drops, and semantic blank screens. Each of those needs a purpose-built response, and the orchestrator's only job is choosing which one to invoke.
Silent recovery is the goal. The best error handling is the kind the user never sees. L1 succeeds most of the time. The user's mental model stays intact: "I typed a thing, the app appeared." The eight seconds we spent auto-fixing an unclosed <ScrollView> are invisible.
When you must surface, surface actionable. L4 doesn't show a stack trace. It shows a "Fix with AI" button. The right escalation isn't "here's what went wrong" — it's "here's what you can do about it."
For a broader look at how the AI pipeline is structured before these error layers even kick in, see our four-step LLM pipeline for React Native generation and how we prevent AI code hallucinations. If you're curious about testing rather than recovery, how we test AI-generated code at scale covers the other half of this problem.
The dependencies that make it possible
For anyone building similar infrastructure, the actual package.json behind our error handling looks like:
@sentry/nextjs@^10.21.0— production error trackingzod@^3.25.36— schema validation on LLM outputs and user promptsai@^4.3.19— the Vercel AI SDK for LLM streaming primitivesinngest@^4.2.4— background jobs with idempotency and graceful failuresonner@^2.0.3— non-blocking toast notifications@vercel/kv@^3.0.0/@upstash/redis@^1.37.0— KV storage for resumable streamspersistent-request-response@^0.1.2— the SSE resumption library that sits on top of KV
There's nothing exotic in that list. The novelty isn't the tools — it's the layering.
Try it yourself
If you want to see all of this in action without reading any of the code: type a prompt into RapidNative and try to break the generated app. Feed the AI something ambiguous. Ask for something the model shouldn't quite know how to do. Watch what happens when it fails — because most of the time, you won't notice it did.
That's the whole point. The error handling that works best is the error handling you never see. And if you do see it, it's because we decided you should — with a button that fixes the problem in one click.
Ready to build a mobile app without babysitting error messages? Start free at rapidnative.com, sketch your first screen on our whiteboard-to-app canvas, or turn a PRD into a working prototype. See pricing for how our credit system works.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.