How RapidNative Generates Production-Grade State Management in React Native

RI

By Rishav

31st May 2026

Last updated: 31st May 2026

How RapidNative Generates Production-Grade State Management in React Native

Most AI app builders treat state management like an afterthought. Ask one to build you a todo app and you'll get a useState hook in App.tsx. Ask for something with auth, lists, and an offline mode and you'll get a tangle of effects, race conditions, and stale data. The generated code "works" in the preview, then falls apart the moment a real user opens it on a flaky subway connection.

We refused to ship that. Every React Native app RapidNative generates uses the same opinionated state architecture — server state in TanStack Query, a single React Context for the database client, and useState reserved strictly for ephemeral UI. There are no Redux stores, no Zustand atoms, no localStorage hacks. Just one stack, enforced by the scaffold, the system prompt, and a hard ban list.

This post is the engineering breakdown of why we made each of those decisions, and how the AI is constrained to produce them — every single time.

Developer working on mobile app code Photo by Christopher Gower on Unsplash

Why generic state management advice fails for AI-generated code

When a human picks a state library, they make a judgment call based on the app's complexity, the team's experience, and the long-term maintenance horizon. An AI doesn't have a team. It doesn't have a maintenance horizon. It has training data — and that training data is overwhelmingly biased toward whatever the open-source community wrote the most articles about between 2018 and 2023, which means Redux boilerplate, leaky useEffect patterns, and homemade context wrappers that re-render the entire tree on every keystroke.

If you let an LLM "decide" the state architecture for each new screen, you get a different decision per screen. One file uses useState. The next uses a custom hook with a useReducer. A third reaches for a global singleton because the model was probably trained on a Stack Overflow answer from 2019. Three weeks later, none of them talk to each other, and the user complains that the app shows different cart totals in different places.

The fix is not better prompting. The fix is removing the choice entirely. RapidNative's generated apps don't have a state management debate because the AI is never given the option to have one.

The stack that ships in every generated app

Every project generated by RapidNative starts from a fullstack scaffold — a complete Expo + React Native project, pre-wired before the AI writes a single line. The scaffold's package.json locks in the runtime decisions:

"@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/query-async-storage-persister": "^5.90.18",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-query-persist-client": "^5.90.18",
"@vibecode-db/client": "3.0.4",
"expo-router": "6.0.12",
"react-native": "0.81.4",
"nativewind": "4.2.1"

That's the entire state stack: TanStack Query v5 for server state, AsyncStorage for persistence, React Context (one of them, in AppProvider.tsx) for dependency injection of the database client. Notably absent: Redux, Zustand, Jotai, Recoil, MobX, Valtio. The scaffold is shipped with these libraries intentionally not installed — adding them would require the AI to modify package.json, which the system prompt forbids.

This isn't a stylistic preference. It's a deliberate architectural choice grounded in three observations:

  1. The vast majority of mobile-app state is server state. Lists, profiles, posts, messages, settings — almost all of it lives on a backend, gets fetched, cached, and mutated. A library purpose-built for server state (TanStack Query) handles roughly 80% of every app's needs out of the box.
  2. React Context is sufficient for cross-cutting dependencies like a database client or theme. It only becomes a footgun when you try to use it for reactive state, which we don't.
  3. useState is enough for everything else. Modal open/closed. Form input drafts. Tab indices. None of that needs a library.

Everything we generate maps cleanly onto those three buckets. If a problem doesn't fit, that's a signal we picked the wrong abstraction — not that the user needs to install another library.

The system prompt is the real architecture document

In a traditional codebase, conventions live in a style guide that nobody reads. In an AI-generated codebase, conventions live in the system prompt — and they're enforced on every single generation. RapidNative's React Native system prompt includes a section that reads, in essence:

  • Use TanStack Query v5 with object syntax for all data fetching: useQuery({ queryKey, queryFn }). No legacy positional arguments.
  • Never call hooks at module scope. const queryClient = useQueryClient() outside a component crashes with "Invalid hook call."
  • Default query data to a safe value: data: items = [] to prevent FlatList crashes when the cache is empty.
  • Use the query-key convention ['resource', userId] for everything user-scoped. Never invent ad-hoc keys.
  • Cap list queries at .limit(50) unless explicit pagination is in scope.
  • All mutations invalidate the relevant query on success: onSuccess: () => queryClient.invalidateQueries(...).
  • Never call client.auth.* directly in a screen. Use the useAuth() hook, which already wraps everything in mutations with the right invalidation logic.

Read those rules side by side and a pattern emerges: every one of them is something a competent React Native engineer would tell a junior dev on day one. They're the small, boring conventions that decide whether a codebase ages gracefully or rots into a series of one-off workarounds. The difference is that we've encoded them into the prompt so the AI applies them on every generation, not just the first.

Mobile app interface on phone screen Photo by Hal Gatewood on Unsplash

The query-key convention that prevents cache chaos

The ['resource', userId] convention sounds trivial until you've debugged a real cache invalidation bug. Here's what goes wrong without it.

Imagine an AI generates a posts screen with queryKey: ['my-posts'], a profile screen with queryKey: ['posts', userId], and a feed screen with queryKey: 'feed-data' (the AI helpfully decided strings were cleaner than arrays). When the user creates a new post and the mutation runs invalidateQueries({ queryKey: ['posts'] }), exactly one of those three queries refetches. The other two display stale data until the user pulls to refresh. The bug ships, the user files it, and the AI cannot reproduce it because the next generation made different choices.

By forcing every query through the [resource, userId] shape, invalidation becomes predictable: invalidate ['posts'] and every per-user posts query in the cache refetches. The convention also makes the cache itself debuggable — you can open React Query DevTools and read the query tree like a database schema. We get this for free because the scaffold and the system prompt agree on the shape.

The two-mode QueryClient: designer mode vs standalone

The same generated app runs in two very different environments, and they need very different caching behavior. Inside the RapidNative editor's preview iframe, a designer might be editing data live — adding a row, tweaking a field — and they want to see the change instantly, not five minutes later when the cache happens to invalidate. On a real user's phone, the opposite is true: the app should serve stale data instantly, refetch in the background, and survive a complete network outage.

The scaffold's src/lib/queryClient.ts solves this with a single environment variable:

const isDesigner = process.env.EXPO_PUBLIC_ENV === 'designer'

export const queryClient = new QueryClient({
  defaultOptions: isDesigner
    ? {
        queries: {
          staleTime: 0,
          gcTime: 0,
          retry: 2,
          networkMode: 'always',
        },
        mutations: { retry: false, networkMode: 'always' },
      }
    : {
        queries: {
          staleTime: 1000 * 60 * 5,      // 5 minutes
          gcTime: 1000 * 60 * 60 * 24,    // 24 hours
          retry: 2,
          networkMode: 'offlineFirst',
        },
        mutations: { retry: false, networkMode: 'offlineFirst' },
      },
})

In designer mode, staleTime and gcTime are both zero — every render fetches fresh, nothing is cached, and networkMode: 'always' means queries fire even if the network adapter claims to be offline (because the iframe's "network" is the parent window, which doesn't always report status correctly).

In standalone mode, queries stay fresh for 5 minutes, garbage-collect after 24 hours, and use networkMode: 'offlineFirst' — which means TanStack Query serves the cached value immediately and only attempts a network fetch if no cache exists. The user sees their data the instant they open the app, even on the subway.

This is the kind of duality that's easy to overlook when you're hand-writing one app, and impossible to ignore when you're generating thousands.

Persistence that survives offline, with one critical security carve-out

For the standalone mode to actually work offline, the cache has to outlive the process. The scaffold pipes TanStack Query's cache into AsyncStorage via the official persister:

export const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'REACT_QUERY_OFFLINE_CACHE',
  throttleTime: 1000,
  serialize: (data) => JSON.stringify(data),
  deserialize: (data) => JSON.parse(data),
})

export const persistOptions = {
  persister: asyncStoragePersister,
  maxAge: 1000 * 60 * 60 * 24,
  dehydrateOptions: {
    shouldDehydrateQuery: (query: any) => {
      if (query.state.status === 'error') return false
      if (query.queryKey[0] === 'auth') return false
      return true
    },
  },
}

Two of those defaults deserve attention. The throttleTime: 1000 debounces writes to AsyncStorage so a screen that triggers ten mutations in quick succession doesn't write to disk ten times — AsyncStorage on Android is notoriously slow under write contention. And the dehydrateOptions.shouldDehydrateQuery filter does two things at once: it skips persisting failed queries (so we never replay a broken response from cache on next launch), and it refuses to persist anything under the auth query key. Session tokens, user records, anything wrapped by useAuth — none of it touches AsyncStorage.

That last rule isn't aesthetic. AsyncStorage on iOS and Android is plain, unencrypted disk storage by default. Persisting a session token there is the kind of "small convenience" that ends up on a security audit's front page. We don't trust the AI to remember that on every generation, so the scaffold refuses to persist auth at all.

Smartphone showing app screens Photo by Rodion Kutsaiev on Unsplash

Auth as a hook, not a slice

A huge category of state-management bugs in mobile apps trace back to two screens disagreeing about whether the user is logged in. The login screen finishes its mutation, navigates away, but the home screen's auth check ran on mount with the old session and never re-read it. The user sees a "Please log in" message immediately after logging in successfully.

The scaffold's src/hooks/useAuth.ts solves this with a single React Query session query plus three mutations that all invalidate the same query key on success:

export const authKeys = {
  session: ['auth', 'session'] as const,
  user: ['auth', 'user'] as const,
};

export function useAuth() {
  const { client } = useApp();
  const queryClient = useQueryClient();

  const sessionQuery = useQuery({
    queryKey: authKeys.session,
    queryFn: async () => {
      const { data, error } = await client.auth.getSession();
      if (error) throw error;
      return data.session;
    },
    staleTime: 0,
  });

  const signIn = useMutation({
    mutationFn: async ({ email, password }) => {
      const { data, error } = await client.auth.signInWithPassword({ email, password });
      if (error) throw new AuthError(error.message, error.reason);
      return data;
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.session }),
  });

  const signOut = useMutation({
    mutationFn: async () => {
      const { error } = await client.auth.signOut();
      if (error) throw new AuthError(error.message, error.reason);
    },
    onSuccess: () => {
      queryClient.setQueryData(authKeys.session, null);
      queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== 'auth' });
    },
  });

  return { user, session, isAuthenticated: !!session, isLoading: sessionQuery.isLoading, signIn, signUp, signOut };
}

Three details matter here. First, staleTime: 0 on the session query means every consumer of useAuth() always reads the latest session — there's no caching window where one screen could see "logged in" while another sees "logged out." Second, sign-in and sign-up both call invalidateQueries({ queryKey: authKeys.session }), which forces every subscribed component to refetch the session synchronously. Third, sign-out doesn't just clear the session — it calls removeQueries({ predicate: (q) => q.queryKey[0] !== 'auth' }), which wipes every cached query that belonged to the previous user. The next person to log in starts from a clean cache, not someone else's data.

The system prompt then forbids the AI from ever calling client.auth.signIn(...) directly inside a screen. The only sanctioned path is const { signIn } = useAuth(). That single rule eliminates an entire class of auth-state divergence bugs.

The one Context we ship — and why it's the only one

AppProvider.tsx is the entire React Context surface in every generated app:

export function AppProvider({ children }: { children: ReactNode }) {
  const [client, setClient] = useState<Client | null>(
    () => (window as any).__VIBECODE_CLIENT__ || null
  );

  useEffect(() => {
    if (client) return;
    buildClient(getEnvConfig())
      .then((c) => setClient(c))
      .catch((err) => console.error('[AppProvider] buildClient failed:', err));
  }, []);

  if (!client) {
    return (
      <View className="flex-1 bg-background items-center justify-center">
        <ActivityIndicator size="large" className="text-primary" />
      </View>
    );
  }

  return <AppContext.Provider value={{ client }}>{children}</AppContext.Provider>;
}

It exists to do one thing: hand the database client to every screen via useApp().client. It does not hold reactive state. It does not coordinate updates. It does not re-render its children when anything except the client itself changes (which it never does after mount).

This is React Context being used the way the React team has always recommended — for dependency injection of stable values — and the way it stops being a footgun. The moment you start using Context to hold a reactive object that changes on every keystroke, you get the entire tree re-rendering on every change. That's not a Context limitation; it's a misuse, and one the AI is structurally prevented from making because the scaffold's provider is on the "never edit" list.

The banned list: what AI-generated apps will never contain

The system prompt includes an explicit list of patterns the AI must refuse to generate. The relevant ones for state:

  • No global stores. No createSlice, configureStore, create((set) => ...), atom(...). Server state goes in TanStack Query. Stable dependencies go in AppProvider. Everything else is useState.
  • No direct client.auth.* calls in screens. The useAuth() hook is the only sanctioned auth surface.
  • No hooks at module scope. A favorite LLM mistake is const queryClient = useQueryClient() at the top of a file. It crashes the bundle. The prompt calls this out explicitly.
  • No custom Context for app data. Adding a new createContext for "user profile" or "cart state" is forbidden. Those belong in TanStack Query, keyed on the relevant resource.
  • No AsyncStorage writes outside the persister. Any direct AsyncStorage.setItem for state is a code smell — if it's important enough to persist, it's important enough to be a query.

These bans aren't there because the techniques are wrong in general. They're there because they're inconsistent with the rest of the stack — and consistency, not novelty, is what makes a generated codebase maintainable.

Team reviewing code on a laptop Photo by Annie Spratt on Unsplash

What this means for the code you actually export

Every React Native project built on RapidNative is exportable. You can pull the source down, open it in your own editor, and ship it from your own Expo build. Which means the question "what state library did the AI pick?" matters a great deal — because you're going to inherit and maintain it.

Apps generated by RapidNative ship with:

  • A package.json that contains exactly one runtime state dependency (@tanstack/react-query).
  • A useAuth() hook that any React Native developer can read top-to-bottom in two minutes.
  • A queryClient.ts whose defaults document themselves.
  • No global state singletons, no reducers, no middleware chains, no DevTools-only context, no Provider wrapping Provider wrapping Provider.
  • Code that the TanStack Query docs cover one-to-one — if a developer needs to extend it, the official documentation is the manual.

Compared to apps you'd inherit from a typical agency-built React Native codebase — which often arrive with Redux, redux-saga, redux-persist, a custom useReducer-based auth context, and three competing AsyncStorage wrappers — the result is dramatically less surface area. That matters when you're a non-technical founder handing off your app to your first engineering hire, or when you're a PM trying to read the code in Cursor or VS Code without a guide.

How this fits with the rest of RapidNative's pipeline

State management is one slice of a larger generation pipeline. The same scaffold that locks in TanStack Query also locks in NativeWind for styling, Expo Router for navigation, and a vibecode-db ORM layer for the database — all chosen for the same reason: they let the AI write less code and break in fewer ways. We've written about the broader pipeline in why we run a 4-step LLM pipeline to generate React Native code and about how we stream generated components in real-time.

The pattern is the same in every layer: pick one good answer, ship it in the scaffold, encode the rules in the system prompt, and refuse to let the AI deviate. The result is generated code that feels like it was written by a single experienced engineer, not by a thousand random Stack Overflow answers.

People also ask

Why doesn't RapidNative use Redux or Zustand for generated apps?

Both are excellent libraries for client state that isn't server-derived. The honest answer is that the vast majority of state in a typical mobile app is server-derived — lists, profiles, posts, settings — and shoehorning that into Redux or Zustand means manually reinventing what TanStack Query gives you for free: caching, deduplication, background refetch, optimistic updates, retry, and offline support. We do use Redux Toolkit inside the editor itself (where the state is genuinely client-only canvas state). For generated apps, it's overpowered.

What about local UI state — does the AI use useState or useReducer?

useState for almost everything. The system prompt allows useReducer only when the state genuinely involves multiple coordinated transitions (a multi-step form wizard, for example). For a single boolean modal flag or an input draft, useState is the convention.

How does the generated app handle offline?

The standalone-mode QueryClient sets networkMode: 'offlineFirst', which means cached data is served immediately and the network fetch is attempted in the background. The cache itself is persisted to AsyncStorage with a 24-hour TTL via @tanstack/react-query-persist-client. The result is an app that opens to real data even when the device has no network, then syncs once it does.

Can I add Redux to a RapidNative app after I export it?

Yes. Once the project is exported, it's a standard Expo + React Native codebase — you can install anything you want and refactor however you like. We just don't ship it that way by default, because for most apps it's a step backwards from TanStack Query.

The takeaway

State management in AI-generated React Native code is a problem you solve once, in the scaffold and the system prompt, or you solve it badly a thousand times in the output. RapidNative's answer is to pick one stack — TanStack Query for server state, a single React Context for the database client, useState for everything else — and enforce it ruthlessly. The result is generated apps that read like they were written by one engineer who knows what they're doing, that handle offline out of the box, that ship without security holes in the auth cache, and that you can actually maintain after the AI hands them over.

If you want to see it in action, you can build a React Native app with RapidNative for free, export the source, and read every line of the state code yourself. It's the same scaffold described in this post — no surprises.

Ready to Build Your App?

Turn your idea into a production-ready React Native app in minutes.

Try It Now

Free tools to get you started

Frequently Asked Questions

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.