Redux, Context, and Beyond: State in RapidNative's AI Editor
By Rishav
15th Jul 2026
Last updated: 15th Jul 2026
Most tutorials about React state management assume a boring runtime. A user clicks a button, the reducer runs, the component re-renders. Simple, sequential, human-paced.
Then you build an AI code editor.
Now your state has to accept a Server-Sent Event stream that emits thousands of file-content mutations per generation, keep a live React Native preview in an iframe hot-reloading in real time, track multiple humans editing the same project over a socket, survive a network drop mid-stream, and never let the source-of-truth for the current team drift between a Redux slice, a session storage bucket, and a Postgres row. All at once. All without dropping a keystroke.
This is a deep dive into how RapidNative — the AI mobile app builder that turns natural-language prompts into shippable React Native and Expo screens — actually handles that. Real files, real modules, real trade-offs. If you're building any product that streams AI output into a running UI, most of these patterns generalize.
State management in an AI editor looks less like a shopping cart and more like a live wire. Photo by Christopher Gower on Unsplash.
What state management in an AI code editor actually means
State management in an AI code editor is the discipline of coordinating high-frequency, asynchronous, streaming updates — from an LLM, a preview sandbox, a collaboration socket, and the user's own edits — into a single consistent view that any part of the UI can render without tearing. It combines classic Redux patterns for shared state with imperative, ref-based stores for I/O the framework can't safely re-render on every tick.
That definition is important because it tells you what not to reach for. React Query is built for server data with a request/response lifecycle. It doesn't fit token-by-token streams. Plain useState doesn't fit either — the editor has too many decoupled surfaces that all need to see the same "is the AI still generating?" flag at the same instant.
So we ended up with a hybrid: Redux Toolkit for the shared brain, React Context for isolated feature slices, socket.io for cross-user sync, and a handful of ref-based imperative stores for the truly hot paths.
The stack, in one honest table
Before the deep dive, here's the full state-related dependency footprint, straight from package.json:
| Library | Version | Role |
|---|---|---|
@reduxjs/toolkit | ^2.8.2 | Store, slices, thunks, RTK Query base |
react-redux | ^9.2.0 | <Provider>, useSelector, useDispatch |
| React Context (built-in) | React 18.2 | Milestone tracking, survey, message manager |
socket.io-client | ^4.8.1 | Real-time file + chat sync across tabs and users |
idb-keyval | ^6.2.2 | IndexedDB for VFS snapshots and large file caches |
next-auth | ^4.24.11 | Session, wraps in a SessionProvider |
next-themes | ^0.4.6 | Dark/light mode persistence |
| Session storage (browser) | — | Tab-scoped selected team ID |
| Local storage (browser) | — | Draft prompt names, component library choice |
Notice what is not in that list: no React Query, no SWR, no Zustand, no Yjs, no redux-persist. Every one of those was evaluated and consciously dropped. React Query is designed around cache invalidation for request/response endpoints; we needed streaming plus imperative writes. Zustand is fantastic for small stores, but the editor has enough cross-cutting logic (retries, recovery, message routing) that centralized middleware pays for itself. Yjs and CRDTs are overkill for our collaboration model — projects have few simultaneous editors and human-scale edit rates, so last-write-wins with a socket broadcast is plenty.
Why Redux at all in 2026
The React community has, fairly, spent five years chipping away at Redux's mindshare. For most CRUD apps, Zustand or Jotai or plain context is enough. So why do we still use Redux Toolkit for the editor?
Three reasons, in order of importance:
- Middleware is a first-class integration point. Our custom
messageManagerMiddlewareintercepts every dispatched action to route messages between the editor UI and the preview iframe. RTK Query'sbaseApi.middlewarehandles caching for typed endpoints. Without middleware, we'd be sprinkling side-effect coordination throughout components. - Redux DevTools is unreasonably good. When a user's AI generation goes sideways, being able to replay every action leading to the failure is the difference between a two-hour bug hunt and a five-minute one.
- Immer lets us write reducers that read like mutations. Under the hood, Redux Toolkit uses Immer, so
state.files[path] = newFilecompiles to a safe immutable update. This matters when a slice holds afilesmap with 40+ entries and needs surgical updates.
The trade-off is boilerplate. We accept that. Every slice pays a small syntactic tax in exchange for the debuggability and middleware benefits above.
The slices, one by one
The store composition lives in src/modules/editor/store/. Five slices carry the load:
appSlice — session-level state. Selected team ID (which is not the same as the one on the users row in Postgres — more on this shortly), credit balance, subscription tier, the user's project list, and a handful of loading flags. Everything a dashboard needs to render.
editorSlice — the biggest slice. It holds files (a map keyed by path), the selectedFile, artboards (screens visible on the canvas), the current zoomLevel and device, the messages array for the AI chat, the isAiRequestInProgress boolean that gates almost every interactive control, and the activeUsers array for multi-user presence. When something in the editor changes, it almost certainly touches this slice.
themeEditorSlice — colors, radius, and typography for the theme editor. Isolated because it has a distinct save lifecycle (isDirty, isSaving) that doesn't need to bleed into the main editor.
integrationSlice — connected integrations per project (Supabase, Stripe, etc.), managed with createAsyncThunk and its pending/fulfilled/rejected lifecycle. This is the one place we lean hard on RTK's built-in async patterns rather than hand-rolled thunks.
recoverySlice — the escalation ladder for when generation goes wrong. currentLevel runs 0 through 4, from silent retry all the way up to full page reload. surfacedError is the user-visible message when we finally give up.
The store is created lazily via a createStore() factory that also instantiates a MessageManager and a RecoveryOrchestrator. Those two objects hold references to store.dispatch and store.getState so they can operate outside React's render cycle — critical for imperative work like reading the current state from a socket handler.
React Context, and where we deliberately don't use it
If Redux is the shared brain, React Context is the nervous system for features that don't need to notify the whole app. Four contexts do real work:
MilestoneContext— tracks user achievements like "first project created" or "watched the intro video." Backed by/api/user/milestones, updated via custom DOM events. Nothing else needs to know when a milestone fires; encapsulating it in a context keeps the reducer surface small.SurveyContext— visibility of the in-app NPS survey, plus completion state.SupportChatContext— the state for the support widget (open/closed, unread messages).MessageManagerContext— exposes theMessageManagersingleton to any component that needs to send a message into the preview iframe (e.g., "enable selection mode" when the user activates point-and-edit).
The rule we've settled on: if two unrelated parts of the app care about a piece of state, it goes in Redux. If a feature owns its state end-to-end and only needs to inject a few values into a subtree, it goes in Context. Milestones are a good fit for Context because the analytics layer, the tour, and the dashboard all read them but nothing outside the milestone flow mutates them.
We don't use Context for high-frequency updates. React Context re-renders every consumer on any value change, which for a messages array that mutates on every SSE token would grind the browser to a halt. That data lives in Redux, where useSelector with a shallow-equal check keeps re-renders surgical.
Streaming AI output into a live editor means orchestrating four subsystems in parallel: the LLM, the store, the VFS, and the preview iframe. Photo by Umberto on Unsplash.
The AI streaming loop: where the interesting state work happens
This is the section that matters most if you're building anything similar. When a user hits Enter on a prompt, here's what actually happens to state, in order:
- Optimistic dispatch. The
sendMessagethunk fires, and immediately dispatches two temporary messages intoeditorSlice.messages— the user's message, plus a placeholder assistant message withisLoading: true. This lets the chat UI render the pending state before the network has done anything. setIsAiRequestInProgress(true). This one boolean gates roughly forty interactive controls across the editor. Dispatching it once is far cleaner than passing a prop tree.- POST to
/api/user/ai/generate-v2. The endpoint returns a Server-Sent Events stream, not a JSON body. - Chunk-by-chunk parsing. The client reads the response body with a
ReadableStreamReaderand splits on SSE framing. Every chunk is either atextevent (assistant token), afile_updateevent (a full or partial React Native file), or atool_resultevent (metadata from the pipeline's context-gathering phase). - Debounced file saves. For
file_updateevents, we don't dispatch immediately. We queue through a per-file 100 ms debounce so a burst of updates for the same path collapses into one Redux write. Without this, the Babel transformer in the preview bundler would run six times per second and freeze the tab. - Skip-the-backend during stream. Each
saveFilecall takes ashouldUpdateBackendflag. During the stream it'sfalse— we write to the Virtual File System (in-memory) and dispatch to Redux, but skip the API call to persist. That saves an order of magnitude in network round-trips while streaming. - Final flush. When the SSE stream sends
event: finish, we callsaveFileone more time per touched file withshouldUpdateBackend: trueso everything lands in Postgres. - Clear the flag.
setIsAiRequestInProgress(false)re-enables the UI, andsetLastAiGenerationTimestamp(Date.now())gives any observer (analytics, the recovery orchestrator) a timestamp to work with.
The pattern that keeps this manageable is separating the hot path (streaming into memory and re-rendering the preview) from the durable path (persisting to Postgres). If we tried to save every SSE chunk to the database, the API would be the bottleneck, not the LLM. If we tried to skip the durable path entirely, a browser crash mid-generation would erase the user's work. Two-phase save gives us both.
Cross-iframe state: the "Vibecode" shared table
RapidNative's live preview isn't one iframe — it's often several, because a screen might link to sub-screens, and users can pop out individual artboards to inspect them. All of those iframes need to see the same in-memory database when the generated app uses a mock data table.
The naive approach — each iframe carries its own copy of the data — breaks the moment the user adds a row on one screen and navigates to another. So we run a single shared Map<string, Record<string, any>[]> in the parent window and hand each iframe a pointer to the same reference. When any preview screen mutates a row, every other preview sees it instantly because they're all reading from the same object.
This lives outside Redux on purpose. The shared tables are updated at frame-rate by user gestures inside the preview; dispatching a Redux action for every keystroke inside a preview text field would be silly. Instead, the store just holds a sharedTablesReady flag; the actual data is a plain object accessed by reference.
Persistence: the four tiers
State that survives page reload doesn't come from a single place. It comes from a hierarchy:
Tier 1: Redux (runtime). Ephemeral. Rebuilt on every page load.
Tier 2: sessionStorage (tab-scoped). This is where the currently selected team ID lives. Session storage is critical here because it's per-tab: a user can open two RapidNative tabs and switch teams independently. Local storage would break that. The database value users.selected_team_id is only a fallback for the first request when a new tab has no session storage yet. Once the tab hydrates, Redux + session storage own the truth, and the DB row is deliberately not written back on team switches. This is the kind of invariant that's easy to violate accidentally and painful to debug when the wrong team's projects start loading.
Tier 3: localStorage. Draft prompt text, component-library choice (Gluestack vs NativeWind), theme preference. Small, non-sensitive, cross-tab.
Tier 4: IndexedDB via idb-keyval. Snapshots of the Virtual File System that are too big for local storage's 5 MB budget. When a user reloads a project, we can hydrate the VFS from IndexedDB before the API responds, so the preview flashes back to its last state almost instantly.
Below all of that, of course, is Postgres via Supabase. But nothing in the editor treats the DB as the source of runtime truth. The DB is the source of durability. Redux is the source of truth for right now.
Two-phase save is the pattern that makes real-time streaming and durable persistence coexist. Photo by James Harrison on Unsplash.
Recovery: the timeline pattern
Streaming state is fragile. Networks blip, LLM providers rate-limit, tabs get backgrounded and pause their event loops. If the AI has been "generating" for thirty seconds with no new chunk, something is stuck.
We solve this with a lightweight orchestration layer that sits outside React:
- A
TimelineEventLogrecords every state-relevant event (dispatch, socket message, SSE chunk, HMR tick) with a timestamp. - A
DogWatcherpollsisAiRequestInProgressevery two seconds. If the flag has been true with no timeline activity for more than thirty seconds, it fires an event. - A
RecoveryOrchestratorlistens for that event and escalates through five levels: 0 = silent, 1 = retry the current chunk, 2 = rebuild the preview iframes, 3 = full page reload, 4 = surface an error and stop. Each escalation is a discrete dispatch intorecoverySlice, so the UI can render a matching progress state.
The design philosophy: don't try to catch every failure at the point it happens. Let subsystems fail. Have one supervisor that watches the shape of the state over time and can force a corrective action. This is the actor-model insight applied to a browser tab — and once you've built it, you stop worrying about the long tail of AI-editor failure modes.
Real-time collaboration, without CRDTs
Multiple users can open the same project. When one saves a file, the others should see it. We use socket.io for this rather than any operational-transform or CRDT library, and it's worked well.
The SyncService in src/services/ connects to a socket server, listens for file_updated and message_updated broadcasts, and dispatches straight into the editor slice. Every socket message carries a tabId so we can ignore echoes of our own writes. The activeUsers array is refreshed on join and leave events.
This isn't a real-time-collaboration platform in the Figma sense — two users typing in the same file simultaneously will still race. But for the actual usage pattern (a founder and a designer taking turns to prompt the AI on the same project), last-writer-wins with a socket rebroadcast is exactly right, and it cost us a fraction of what a CRDT integration would have.
Questions we get about this architecture
Why not use React Query for the API layer?
We use it in other RapidNative surfaces, but the editor's server interactions don't fit its model. The main "server call" is an SSE stream, not a fetch. Around it, most other API traffic is user-triggered and doesn't benefit from cache invalidation. Thunks were the honest choice.
Do you use Redux Toolkit's createEntityAdapter?
Not currently. Our files map is small enough (tens to low hundreds of entries) that the extra indirection doesn't pay off. If we grow into large-project territory, that's the first optimization we'd reach for.
How do you handle undo/redo?
Honestly, we don't yet in this layer. Undo/redo is stubbed in the slice and hasn't been fully wired up. When we build it, it will be a separate history stack that snapshots the files slice on each user-driven edit, not a redux-undo middleware — the streaming writes would blow the history stack apart.
Is this overkill for a simpler product?
Yes, if you're building a CRUD app. No, if you're streaming AI output into a live sandbox. The complexity is proportional to what the product actually does. If your editor doesn't have live preview, cross-user sync, and multi-second AI generations, you can absolutely get away with Zustand and a few thunks.
Patterns worth stealing
If you're building something similar, the four ideas that have paid us back most are:
- Two-phase save: fast in-memory write on the hot path, batched durable write at the end. Streaming and persistence are different problems.
- A single boolean flag for "generation in progress", gated globally. It disables the world with one line and enables it just as cleanly. Don't try to derive it.
- A supervisor process that watches state shape, not individual failures. When you have four subsystems that can each get stuck in different ways, you want one watchdog, not four.
- Session storage over local storage for anything tab-scoped, and never treat the DB as runtime truth for state the user can change client-side.
Try it, then build your own
The best way to understand these patterns is to watch them work. Open RapidNative, start a project, and prompt for a screen. Watch the chat update mid-stream, the preview hot-reload before the sentence finishes, the file tree fill in. Every one of those UI transitions is the state machinery above doing its job — and every architectural choice in this post exists because we tried the naive version and it broke.
If you're going to build an AI-powered editor of your own — for design, for docs, for code — start with a Redux Toolkit store, add middleware early, and separate your hot in-memory path from your durable path from day one. Everything else is a variation on that theme.
And if you'd rather just ship a mobile app instead of building the editor, start from a prompt, a screenshot, or a sketch — we've already done the state work for you.
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.