How Our AI Handles Complex State: Redux, Context, and Beyond
By Suraj Ahmed
20th Sep 2026
Last updated: 20th Sep 2026
A user types "add a settings screen with a dark mode toggle" into RapidNative. Thirty seconds later, a working React Native screen renders on their phone, files stream into the file tree, credits deduct optimistically, the preview iframe hot-reloads, and if anything crashes, an auto-fix agent quietly recovers. All of that runs on top of a single, unified state layer inside the browser.
That state layer is what this post is about.
AI state management for a streaming code editor is not a single problem — it's five overlapping ones stitched together. RapidNative's editor coordinates Redux Toolkit for domain state, React Context for singleton services, RTK Query for server-fetched resources, session/local storage for tab-isolated user preferences, and a Virtual File System that runs in parallel to Redux to feed the bundler. Each layer solves a specific problem that the others cannot.
Here's how they fit together in production.
Why a single state store falls apart
The naive answer to state management in a React app is "put everything in Redux." That works until you hit an AI editor.
Consider what has to happen simultaneously when the LLM streams a response:
- Message content updates 20-30 times per second as text deltas arrive
- Tool call arguments stream in as partial JSON
- File contents update as
write_filetool results resolve - The Metro bundler needs to rebuild on every file change
- Redux DevTools need to stay usable — not overwhelmed by 30 actions/second
- The iframe preview needs to hot-reload without losing scroll or state
- Credits deduct optimistically then reconcile against the server
- If the user closes the tab and comes back, generation should resume
A single Redux store dispatching every text delta as an action produces around 1,800 actions per minute per user. React will still render fine — it's the ecosystem around React that breaks. DevTools slows to a crawl. Time-travel debugging becomes impossible. Serialization overhead becomes visible on mid-range laptops.
The answer isn't to abandon Redux. The answer is to use Redux for what it's good at — coordinated domain state with introspection — and route the high-frequency traffic through purpose-built layers that don't need the same guarantees.
Real-time streaming state is where naive Redux patterns break down — Photo by Christopher Gower on Unsplash
Layer 1: Redux Toolkit for editor domain state
The Redux store lives in src/modules/editor/store/store.ts. It's a singleton, configured with Redux Toolkit's configureStore, and holds four slices plus RTK Query's reducer.
The biggest slice, editorSlice, is 1,502 lines. That's not a smell — it's the entire runtime state of a browser-based IDE. It tracks:
- Files:
files: { [path]: File }— the virtual project tree with content and MIME type - Artboards: screens laid out on the canvas with position and size
- Messages: the full chat history with streaming status per message
- Runtime errors: hashed errors from the preview iframe with source paths
- UI state: modals, dialogs, sidebars, dev-tool toggles
- Collaboration: active users on the same project
- Project metadata: slug, backend container status, template type
The second slice, appSlice, holds cross-project state: credit balance broken into daily-free, monthly-free, and paid pools; the current subscription tier; the list of available AI agents; the user's selected team.
A third slice, themeEditorSlice, isolates the design-system editor from the main editor state so changes there don't invalidate unrelated selectors. A fourth, recoverySlice, tracks the auto-fix state machine and its retry counts.
The store's middleware chain is small but load-bearing:
middleware: (getDefault) =>
getDefault()
.concat(messageManagerMiddleware) // Hooks postMessage RPC into actions
.concat(baseApi.middleware) // RTK Query cache lifecycle
Redux Toolkit uses Immer under the hood, which is the reason updating deeply nested state like state.creditsDetails.dailyFreeCreditsUsed += amount is safe. Immer produces a new immutable state from mutation-style code, which keeps referential equality intact for everything that didn't change — and that's what lets memoized selectors avoid re-rendering unrelated components.
Layer 2: Streaming state — throttled, debounced, and dual-tracked
The hardest problem in the whole system is getting AI-generated content from a server-sent-events stream into a Redux store and a preview bundler without either one melting.
When the client hits /api/user/ai/generate-v3, it reads chunks off a ReadableStream and parses SSE events. Every event is either a text delta, a tool call, a tool result, or a reasoning trace. Each one updates the last message in the chat.
Dispatching per-chunk would produce roughly 30 actions per second per user. The sendMessage thunk in editorThunks.ts solves this with a two-part strategy.
For chat updates, dispatches are throttled to 32ms (~30fps). The last update in each 32ms window is guaranteed to flush, so the visible chat never freezes on a stale value. When the tab is backgrounded and document.visibilityState changes, a trailing flush fires so the user sees the latest state the moment they return.
For file writes, the strategy is different. Each file path gets its own 250ms debounce timer. The Metro bundler in the preview is CPU-heavy — every save triggers a Babel transform pass — so we cap file saves at four per second per file. On a backgrounded tab, the browser clamps setTimeout to one second anyway, so we extend the debounce to 500ms to align with what will actually run.
Streaming content lands in two places in parallel:
- The Redux
filesmap — what the file-tree UI displays - The Lifo sandbox Virtual File System — what the browser-based Metro bundler reads
The VFS lives outside Redux entirely because it's not React-consumable state. It's a filesystem watched by a bundler. Putting it in Redux would double the memory footprint of every project (some are >100 files) and add a re-render trigger on every keystroke.
That parallel tracking is a load-bearing decision. During a normal edit, updateFileThunk(path, content) writes to both. During AI streaming, the same thunk skips the backend save and writes only the VFS + Redux, then a batched save runs once streaming completes.
Streaming AI code into a running editor means coordinating dispatch, bundler, and iframe on separate clocks — Photo by Taylor Vick on Unsplash
Layer 3: React Context for singleton services
Not everything belongs in Redux. Some things are singleton services with no readable state — they have methods, not data.
The clearest example is MessageManagerContext. The MessageManager class coordinates postMessage RPC between the editor and the preview iframes. It handles incoming events like selectLayer, hoverElement, pinchZoom, and RPC resolutions. Components need to call messageManager.sendMessageToApp() — they don't subscribe to state on it.
const MessageManagerContext = createContext<MessageManager | null>(null);
export const useMessageManager = () => useContext(MessageManagerContext);
Putting this in Redux would mean serializing a class instance into the store — Redux DevTools would fight it, the middleware would fire on every method call, and time-travel would be meaningless because iframe state isn't rewindable.
AppContext is thinner still: it derives a boolean isLoadingInitialData from two Redux selectors so the top-level layout can gate first-paint. EditorContext is an empty provider used only for hierarchy signaling.
The rule: use Context for singletons and hierarchy, use Redux for anything you'd want to inspect or replay.
Layer 4: RTK Query for server-fetched resources
Redux Toolkit ships with RTK Query, and RapidNative uses it exclusively for server state that has a clear lifecycle: comments and share permissions. Everything else server-fetched — projects list, credits, agents — goes through classic thunks because those flows have optimistic updates and complex reconciliation.
The base API is nine lines:
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Comment', 'File', 'ProjectShare', 'ProjectShareTeamUser'],
endpoints: () => ({}),
});
Endpoints are injected in separate files (commentsApi.ts, shareApi.ts), and the tagTypes system handles cache invalidation. When a mutation invalidates { type: 'Comment', id: 'LIST' }, every query watching that tag refetches automatically. There's no manual cache-busting.
The split between "RTK Query domain" and "Redux thunk domain" comes down to one question: do I need to do anything optimistic between the request and the response? If yes — thunk with applyCreditCharge before the fetch. If no — RTK Query.
Layer 5: Session storage, local storage, and the source-of-truth question
Some state is per-tab. Some is per-browser. Neither belongs in Redux alone, because Redux doesn't survive a reload.
The clearest case is the selected team. A user might have multiple teams and want two browser tabs open on two different teams simultaneously. That rules out localStorage. It also rules out storing the selection only in the database — the database can only hold one value per user, so switching in tab A would silently redirect tab B.
The solution is a three-layer read cascade for initial load:
- Check
sessionStorage['selectedTeamId']— the per-tab pin - Fall back to
users.selected_team_idfrom the database — a first-load default - Fall back to the user's default team from the join table
Once loaded, Redux is the runtime source of truth. Team switches write to Redux and mirror to sessionStorage. Nothing writes back to the database on switch. This is one of the load-bearing invariants of the system, documented in the codebase and worth calling out — a well-intentioned "let's keep the DB in sync" patch would break tab isolation immediately.
Similar reasoning applies to the simpleMode UI toggle. That one is user-scoped, not tab-scoped, so it lives in localStorage. Redux mirrors the resolved value so components can read it synchronously without waiting on a storage read to hydrate.
The Virtual File System, in parallel to Redux
The Virtual File System deserves its own section because it's the one piece of "state" that isn't in Redux at all — and yet it's fundamental to how the editor works.
RapidNative's preview renders inside a Lifo sandbox instance running a browser-based Metro bundler. Metro reads files from a filesystem, not a JavaScript object. So the editor maintains a parallel VFS that mirrors the Redux files map into an addressable filesystem the bundler can watch.
The flow for a normal file save:
User edits file in the editor
→ updateFileThunk(path, content)
→ dispatch(setFileEntry({ path, data })) [Redux updates]
→ projectVFS.addOrUpdateFile(path, content) [VFS updates]
→ bundler rebuild → HMR → iframe hot-update
→ (async) POST /api/user/projects/{id}/files [Server persists]
During AI streaming, the last step is skipped per-file and a bulk save runs after streaming completes. The point is that both layers stay in sync at the source, and neither is derived from the other at runtime — they're peers.
Optimistic updates, and rolling them back
Optimism is a state-management pattern with a cost: if the operation fails, state has to un-happen.
RapidNative applies optimistic updates in two clear cases: credit deduction on generation start, and chat message insertion before the server confirms it.
Credits deduct the instant a user hits send:
applyCreditCharge(state, action) {
state.credits = Math.max(0, state.credits - action.payload);
state.creditsDetails.dailyFreeCreditsUsed += action.payload;
state.creditsDetails.monthlyFreeCreditsUsed += action.payload;
}
Five seconds after generation ends, fetchCredits(teamId, { silent: true }) reconciles from the server. The silent flag matters — it suppresses the full-screen loading state that would otherwise flicker every time.
For chat messages, the pattern is trickier. The thunk creates a temporary user message and a placeholder assistant message with timestamp-based IDs, then dispatches setMessages([...old, tempUser, tempAssistant]). When the server-side message save returns real UUIDs, the thunk swaps IDs in place. If the user aborts mid-generation, both temp messages are removed and any file changes are rolled back from a snapshot captured before streaming began.
The general shape:
- Snapshot the relevant slice before the risky operation
- Apply optimistic changes immediately
- On success: reconcile with server data
- On failure: restore the snapshot, show an error message
This works in Redux because Immer keeps rollbacks cheap — restoring means dispatching one action with the snapshot as the new state, and Immer handles structural sharing on the diff.
Recovery, time-travel, and the boring-but-critical fifth layer
A generation that streams cleanly is easy. A generation that fails halfway is where state management earns its salary.
The recoverySlice holds a five-level state machine. When the preview iframe reports a runtime error, the error is hashed and pushed onto runtimeErrors in editorSlice. If the hash matches an unresolved error and the user hasn't opted out, the recovery orchestrator escalates:
- L1 — Ask the AI to fix the error automatically, tagged
metadata.autoFix: trueso the user knows it wasn't their prompt - L2-L5 — Retries with increasing context, then rollback strategies, then hard failure
Every step is a Redux action, which means every recovery run is inspectable in DevTools. Combined with the dev-only window.__rnLog timeline recorder — which captures VFS changes, Metro rebuilds, HMR events, and iframe navigation — we can reproduce any failed generation from a captured log without needing the original session.
This is the argument for Redux over lighter alternatives like Zustand or Jotai in this specific product. For a marketing site or a simple CRUD app, Zustand's ergonomics are better and its footprint is smaller. For a system where users hit weird bugs 500ms into a 30-second streaming generation, being able to replay the exact action sequence is worth the ceremony.
Replayable state transitions turn "worked on my machine" into a reproducible timeline — Photo by Markus Spiske on Unsplash
What we deliberately don't do
A few patterns are conspicuously absent from the codebase, and their absence is intentional.
No redux-persist. Persisting the entire Redux state to localStorage sounds convenient until you realize the file tree, the chat history, and the artboard positions can easily exceed 5MB per project. The rehydration on reload would block first paint. Instead, individual pieces persist to their appropriate storage layer — session for tab-scoped, local for user-scoped, DB for project-scoped — and Redux rehydrates from those sources on init.
No Zustand or Jotai. Both are excellent libraries and both would work for this app. The reason to stick with Redux Toolkit is the middleware ecosystem, the DevTools integration, and — most concretely — the ability to hook messageManagerMiddleware into every action to coordinate the iframe RPC bridge. That coordination sits so deep in the runtime that migrating would touch every generation flow.
No global Context for user data. It would be tempting to put the user object in Context so every component can read useUser(). In practice, user data changes rarely, gets consumed by many components, and needs to trigger memoized selectors — which is exactly what Redux with createSelector is built for. Context would broadcast a re-render to every consumer on any change; Redux with reselect renders only the components subscribed to the specific field that changed.
People also ask
Is Redux still worth using in 2026? For applications with complex, coordinated state that needs introspection and time-travel debugging — yes. For simple CRUD apps, lighter options like Zustand or plain React state with URL params are usually better. The right question isn't "Redux or not" but "does my state benefit from a single, replayable log of transitions?"
How do you handle streaming AI responses in React state? Throttle dispatches to a display-friendly rate — 30fps (~32ms) works well for text. Debounce heavier side effects like bundler rebuilds separately. Flush trailing updates on tab visibility changes so backgrounded tabs don't miss the final state. Store partial results in the same shape as final results so components don't need streaming-specific rendering paths.
When should I use Context vs Redux? Context for singleton services, dependency injection, and low-frequency values that many components need (theme, locale, feature flags). Redux for coordinated domain state you'd want to inspect, replay, or subscribe to selectively. If your Context provider updates more than a few times per session, it probably belongs in Redux.
Try building with it
The full editor is what a user interacts with when they build an app on RapidNative — every action they take, from typing a prompt to editing a screen visually, runs through the state layers described here. If you want to see it in action, start with a natural-language prompt and watch the chat, file tree, and preview stream in parallel. You can also read more about how the editor was built end-to-end, or dig into the streaming architecture that feeds this state layer.
State management gets a bad reputation as boilerplate for its own sake. In a system that has to stream, optimistically render, reconcile, recover, and replay — all in a browser tab — the right layers in the right places are what make the whole thing feel instant.
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.