How We Handle State Management in AI-Generated React Native Apps

RI

By Rishav

15th Aug 2026

Last updated: 15th Aug 2026

How We Handle State Management in AI-Generated React Native Apps

React native state management is where AI code generators break first. The LLM has read every Redux tutorial ever written, so on prompt one it hands you Redux Toolkit, three slices, five selectors, and a saga — for a to-do list. On prompt two, it wires useState around a Supabase call and mirrors the server response into local state. Now you have two copies of the truth, and neither of them refreshes.

We hit both failure modes early at RapidNative, the AI mobile app builder that turns a prompt into a real iOS and Android app. So we wrote rules. The AI is not free to invent an architecture per prompt. Every generated app follows the same four-layer state model, uses the same libraries in the same places, and refuses to import the ones we banned. This post is the playbook.

Developer working on React Native code on a laptop screen A working React Native app requires four coordinated state layers — screen, session, server cache, and persisted data — Photo by Clément Hélardot on Unsplash

Why AI-generated apps break state management first

State is the hardest thing to generate correctly because the code that works in isolation rarely stays correct once the app grows. useState is fine for a single screen. It stops being fine the moment two screens need to see the same data. A Context Provider is fine until you put your entire user session inside it and everything re-renders on every keystroke. Redux is fine until the LLM writes 80 lines of boilerplate for a value one component reads.

Every AI code generator has a bias toward the pattern most common in its training data. For React Native, that's Redux with redux-thunk, circa 2019. That default is the wrong answer for 90% of the apps people build with prompts — CRUD, forms, lists, auth, a settings screen. What those apps need is a clear boundary between UI state and server state, offline resilience, and no ceremony. That's what we generate. What we don't generate matters just as much: no Redux in the user's app, no Zustand, no Jotai, no Recoil, no MobX. One state library per layer, no exceptions.

The four-layer model below is what we teach the LLM through the system prompt. It's also what the scaffold ships pre-wired so the AI never has to invent it.

The four layers we ship

Every RapidNative-generated app is built on the same four layers. Each has one library, one location in the codebase, and one job. Nothing crosses layers.

LayerJobTechnologyWhere it lives
1. Screen-local stateEphemeral UI: modal open, input focus, form draftsuseState, useReducerInside each screen component
2. Session contextDependency injection: the DB client, the themeReact.createContextsrc/providers/*.tsx
3. Server cacheEverything fetched from the backendTanStack Query 5.90src/hooks/use*.ts
4. Persisted dataAuth session, query cache, user preferencesAsyncStorage + Vibecode DBsrc/db/ + query persister

Answer paragraph for the featured snippet: AI-generated React Native apps need four separate state layers because each type of state has a different lifetime and a different consumer. Screen state dies with the screen. Session state lives as long as the app is open. Server state has to reconcile with a remote source of truth. Persisted state has to survive a cold start. Collapsing them into one library — the mistake most AI-generated code makes — creates bugs at every boundary.

Below is what each layer looks like in the generated code, and why we picked it.

Layer 1: Screen-local state — where useState is the right answer

The screen a user is looking at owns its own UI. The keyboard is focused or it isn't. The date picker is open or it isn't. The password field is masked or it isn't. None of that needs to survive navigation, none of it needs to sync anywhere. This is where useState and useReducer belong, and the AI is instructed to reach for them first — not last.

export default function LoginScreen() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [showPassword, setShowPassword] = useState(false);

  const { signIn } = useAuth();

  return (
    <View className="flex-1 bg-background p-6 justify-center">
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
        className="border border-border p-3 rounded-lg mb-3"
      />
      <TextInput
        value={password}
        onChangeText={setPassword}
        secureTextEntry={!showPassword}
        placeholder="Password"
        className="border border-border p-3 rounded-lg mb-3"
      />
      <Pressable onPress={() => signIn.mutate({ email, password })}>
        <Text className="text-primary">Sign in</Text>
      </Pressable>
    </View>
  );
}

Three things worth noticing:

  1. Form drafts stay local. The generator never lifts them to a store. If you navigate away from a half-filled form, it's gone — that's a UX decision, not a technical one, and drafts should be explicit persistence, not accidental global state.
  2. useAuth() is a hook, not a state prop. Everything that needs to survive the screen goes through a hook — no drilling props through five layers of container components.
  3. signIn.mutate(...) is a TanStack Query mutation. The local state (email, password) hands off to the server-state layer at the exact moment it needs to. That handoff is the whole point of layering.

If a component needs more than four useState calls or the fields depend on each other, we generate a useReducer instead. If it needs to be shared with a sibling — but not the whole app — we lift it via props or component composition. We do not reach for Context.

Layer 2: The one Context we ship

Every RapidNative app ships exactly one Context Provider that isn't from a library. It's called AppProvider, and its job is dependency injection — nothing else.

// src/providers/AppProvider.tsx
const AppContext = createContext<AppContextValue | null>(null);

export function useApp() {
  const ctx = useContext(AppContext);
  if (!ctx) throw new Error('useApp must be used within AppProvider');
  return ctx;
}

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

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

  if (!client) return <ActivityIndicator />;

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

That's the entire surface area. Screens call const { client } = useApp() and then client.from('workouts').select('*'). There is no auth state in this context, no user profile, no theme, no navigation flags. Every one of those lives in another layer.

Why so minimal? Because React Context is a re-render trigger. Every consumer of a Context re-renders when the provider's value changes. Stuff enough into one Context and every keystroke redraws half the app. The RapidNative editor learned this the hard way — our own editor uses Redux for exactly this reason, because the editor's global state changes hundreds of times per second and Context couldn't keep up. But user apps don't have that problem — they're mostly CRUD screens — so we don't force the boilerplate on them.

The other Contexts you'll see in a RapidNative-generated app come from libraries: QueryClientProvider from TanStack Query, SafeAreaProvider, ThemeProvider (which reads NativeWind's useColorScheme). None of them hold application data. They inject infrastructure.

Layer 3: Server cache — TanStack Query as the source of truth

Everything the app fetches from Supabase or a remote API lives in TanStack Query. The rule the AI follows: the server response is the truth, and we cache it — we never copy it into useState.

// src/hooks/useWorkouts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useApp } from '@/src/providers/AppProvider';
import { useAuth } from '@/src/hooks/useAuth';

const workoutKeys = {
  all: ['workouts'] as const,
  list: (userId: string) => ['workouts', userId] as const,
};

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

  const workoutsQuery = useQuery({
    queryKey: workoutKeys.list(user?.id ?? ''),
    queryFn: async () => {
      const { data, error } = await client
        .from('workouts')
        .select('*')
        .eq('user_id', user!.id)
        .order('created_at', { ascending: false })
        .limit(50);
      if (error) throw error;
      return data;
    },
    enabled: !!user?.id,
  });

  const addWorkout = useMutation({
    mutationFn: async (input: { title: string }) => {
      const { data, error } = await client
        .from('workouts')
        .insert({ ...input, user_id: user!.id })
        .select()
        .single();
      if (error) throw error;
      return data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: workoutKeys.list(user!.id) });
    },
  });

  return { workouts: workoutsQuery.data ?? [], addWorkout };
}

A few of the disciplines we bake in through prompt rules:

  • Query keys are typed and namespaced. workoutKeys.list(userId) is a factory, not a string literal, so a rename in one place doesn't leave stale keys elsewhere.
  • Mutations invalidate, they don't set. The AI never writes queryClient.setQueryData(...) unless it's clearing on sign-out. Every insert/update/delete invalidates the affected query and lets the next fetch be the source of truth.
  • .limit(50) on every list query. The mock database in dev doesn't complain about unbounded selects; production Supabase does, and slowly. We instruct the AI to bound every list.
  • enabled: !!user?.id. Queries never fire before the auth layer resolves. This one detail eliminates a class of race-condition bugs that used to fill our error reports.

The TanStack Query cache is also the offline layer. Every RapidNative app wraps its QueryClientProvider in PersistQueryClientProvider and dehydrates the cache to AsyncStorage:

// src/lib/queryClient.ts
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,      // 5 minutes
      gcTime: 1000 * 60 * 60 * 24,   // 24 hours
      networkMode: 'offlineFirst',
    },
  },
});

export const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'REACT_QUERY_OFFLINE_CACHE',
  throttleTime: 1000,
});

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

networkMode: 'offlineFirst' means a cold-launched app on the subway shows the last-known data instantly, then reconciles when the phone reconnects. shouldDehydrateQuery explicitly excludes auth — a stale session cached to disk is a security bug, not a feature.

React Native app running on a physical iPhone in someone's hand Offline-first is the default because a phone in the real world drops connectivity constantly — Photo by Rob Hampson on Unsplash

Layer 4: Persisted data — AsyncStorage and Vibecode DB

Two things need to outlive an app restart: the auth session (so users don't sign in every time) and the query cache (so the first paint isn't a spinner). Both go through AsyncStorage. The auth handoff is wired into the Supabase client itself:

// src/db/client.ts (fullstack-supabase variant)
export const supabase = createClient<Database>(url, anonKey, {
  auth: {
    storage: AsyncStorage,
    persistSession: true,
    autoRefreshToken: true,
    detectSessionInUrl: false,
  },
});

persistSession: true writes the JWT to AsyncStorage on sign-in; autoRefreshToken: true handles refresh in the background. The useAuth hook wraps client.auth.getSession() in a TanStack Query with staleTime: 0, so the moment the token refreshes, every screen that reads user re-renders with the fresh session.

Longer-lived state — user-authored content, drafts, offline queue — goes into the actual database. For the fullstack-supabase template that's plain Supabase with a SQL schema. For fullstack-v2 it's Vibecode DB, our adapter-based ORM that can point at a mock adapter in dev (backed by AsyncStorage) or Supabase in production, without changing the calling code:

if (config.type === 'mock') {
  const { MockAdapter } = require('@vibecode-db/client/adapters/mock');
  const adapter = new MockAdapter({ persistSession: true, storage: AsyncStorage });
  adapter.setSchema(...Object.values(schema));
  for (const entry of seeds) adapter.seed(entry.table, entry.rows);
  return createClient('mock://localhost', 'mock-key', { adapter });
}

if (config.type === 'supabase') {
  const { SupabaseAdapter } = require('@vibecode-db/client/adapters/supabase');
  const supabase = createSupabaseClient(config.supabaseUrl!, config.supabaseKey!);
  const adapter = new SupabaseAdapter({ ... });
  return createClient(config.supabaseUrl!, config.supabaseKey!, { adapter });
}

The interface is identical. client.from('workouts').select('*') works in both, which is why the AI-generated screens don't care whether the backend is stubbed or live. This decoupling is what makes the instant preview render a populated app the second the generation finishes — no wiring, no seed step, no "connect your database" screen.

The rules the AI never breaks

Rules aren't documentation. If we only wrote them down, the LLM would ignore half of them. They live in the system prompt, they live in the scaffold's CLAUDE.md, and — for the ones that can be checked mechanically — they live in generation-time lints that reject the output before the user ever sees it. The banned-pattern list, verbatim from the template's rules:

Banned patternWhat the AI does instead
import { Provider } from 'react-redux' in a user appNothing — user apps have no Redux
zustand, jotai, recoil, mobx, valtio, easy-peasyNothing — one server-state library (TanStack Query) is enough
useState mirroring the result of a useQueryRead the query result directly
Context.Provider holding user dataMove data into TanStack Query keyed by user.id
AsyncStorage.setItem inside a screen componentUse TanStack Query's persister, or useMutation with onSuccess: () => queryClient.invalidateQueries(...)
A hook called at module top level (const qc = useQueryClient(); outside a component)Move the hook inside the component
useEffect(() => { fetch(...) }, []) for server datauseQuery — never useEffect for fetching
import { PrismaClient } from '@prisma/client'Use the Vibecode DB layer via useApp().client
client.auth.signIn(...) called directly in a screenUse the useAuth() hook — it invalidates the right query keys on success

Some of these look pedantic. They're not. Every one comes from a specific bug we saw in generated code that shipped and broke:

  • useState mirroring useQuery was our #1 bug for two months. A screen reads workouts with a query, then copies them into const [workouts, setWorkouts] = useState(...) to "let the user edit them." Add a workout on another screen, come back, and the list is stale forever because nothing refetches the local useState.
  • useEffect for fetching creates the "double fire in StrictMode, no cancellation, no retry, no cache" pattern that has been documented since 2022 and the AI still writes it unless you tell it not to.
  • Context.Provider holding user data turned one keystroke in a search field into 47 component re-renders because the whole tree consumed the same context.

Rules exist because the LLM will regress to the mean. The mean, for React Native state management, is a 2018 Stack Overflow answer.

What we deliberately don't ship (and why)

We get asked constantly why generated apps don't use Zustand, Jotai, Redux Toolkit, or MobX. The honest answer: they'd solve problems the apps we generate don't have. From the inside-the-state-layer analysis we did earlier:

  • Redux Toolkit solves cross-cutting global UI state with predictable transitions. Great for our editor, where 30 things read the same canvas selection. Overkill for a fitness tracker where every screen reads its own data from Supabase.
  • Zustand is the middle ground: less ceremony than Redux, still a store. But it's a third place for state to live, on top of useState and TanStack Query. Three places to look for a bug is worse than two.
  • Jotai is beautiful atomic state that lets you avoid re-render cascades. The reason to reach for it is a re-render performance problem. The AI-generated screens don't have one — they're mostly single-purpose views over query results.
  • MobX / Recoil / Valtio are all solving variations of the same problem. Picking one adds a library the user has to learn to modify their own app.

We optimize for the second author of the code: the developer who exports the RapidNative project, opens it in VS Code, and needs to understand it in ten minutes. React + TanStack Query is understandable in ten minutes. Redux + a store + slices + selectors + middleware is not.

Team reviewing code and architecture diagrams on a laptop Generated code has to be legible to the human who opens it later — Photo by Brooke Cagle on Unsplash

How this plays out in a real prompt-to-app flow

A user types "Build me a habit tracker with a daily log and streaks." Here's the state architecture the generator lands on, without the user asking for any of it.

Prompt 1 — the scaffold generates:

  • A habits table (id, user_id, name, target_frequency, created_at) plus RLS policies
  • A habit_logs table (id, habit_id, user_id, logged_at)
  • useHabits(), useHabit(id), and useHabitLogs(habitId) hooks, each a TanStack Query
  • useLogHabit() mutation that invalidates useHabitLogs
  • A HomeScreen reading useHabits() and rendering a FlatList
  • Local useState on the "Add habit" modal — form fields only

Prompt 2 — "Add a stats screen showing my streak per habit":

  • A new useHabitStreaks() query hook — no new tables, computed from habit_logs
  • A new app/(app)/stats.tsx screen — pure read from the new query
  • Zero changes to any existing screen

Prompt 3 — "Let me edit a habit":

  • A new dynamic route app/(app)/habits/[habitId].tsx
  • useHabit(habitId) hook (already exists) reads the record
  • useUpdateHabit() mutation is added; on success, it invalidates both useHabits() (the list) and useHabit(habitId) (the detail)
  • The edit form uses local useState for the draft; on submit, the mutation runs and the query cache becomes the truth again

Notice what didn't happen: no store was added, no slice was created, no reducer was written, no context was extended, no prop was drilled. Every prompt adds hooks and screens. The four-layer model is stable across the whole conversation, which is what lets a user string together twenty prompts without the app collapsing into an unmaintainable mess.

People also ask

Do you need Redux for React Native apps?

No. Redux made sense in a pre-hooks, pre-TanStack-Query world where the primary problem was sharing server data across many components. Today, TanStack Query solves the server-data problem better, and hooks solve the sharing problem. Use Redux when you have a lot of cross-cutting client state that changes frequently — a canvas editor, a real-time collaboration cursor, a complex form wizard. For CRUD apps, it's overkill.

Is useContext bad for state management?

useContext is bad for frequently changing application data because every consumer re-renders when the provider's value changes. It's excellent for dependency injection — passing a database client, a theme, or a navigation object. The rule of thumb: if the value in your Context changes more than once per user action, move it out of Context and into TanStack Query or a store.

Why TanStack Query instead of RTK Query for React Native?

Two reasons. First, it's ~15KB smaller and doesn't require Redux. Second, it has first-class offline persistence via PersistQueryClientProvider and AsyncStorage, which matches how mobile apps actually work — users lose connectivity constantly. RTK Query can do offline too, but the pattern is heavier.

How do I share state between screens in Expo Router?

Don't share screen state directly. Move the shared data into the layer that owns it — TanStack Query for server data, AsyncStorage or SecureStore for auth, Vibecode DB / Supabase for anything persistent. Each screen reads from the appropriate hook. This is why the "four layers" model matters: it tells you exactly where each type of shared state should live.

The takeaway

The hardest problem in AI code generation isn't producing code that works. It's producing code that stays working as the app grows, and that a human can pick up six months later without a re-architecture. State management is the pressure test for both. Our answer is not to let the AI pick per prompt. Four layers, one library per layer, a scaffold that ships pre-wired, and a banned-pattern list that stops regressions at generation time.

If you want to see this in action, start a project on RapidNative and export the code — every generated useX hook and every AsyncStorage-persisted query is what this article describes, running on your phone in about a minute. For more on how the editor itself is built (Redux inside, TanStack Query out), see inside the RapidNative editor. For the philosophy behind avoiding boilerplate in generated code, see why we chose Expo over bare React Native.

The layers aren't the interesting part. What's interesting is that they don't move. Consistency, not cleverness, is what lets AI-generated code become a real app.

Start now

Ready to build your app?

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

Free tools to get you started

Questions

Frequently 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.