State Management in React Native Apps

I cover the four state layers in a React Native app — local state, Context, Zustand, and AsyncStorage — and show what's different from managing state in web React.

State Management in React Native Apps

You already manage state in React. useState is second nature, and useReducer is old news. Bring that knowledge into React Native and most of it works exactly as you’d expect — right up until you try to write something to storage and discover that localStorage doesn’t exist on mobile.

That gap is small but it matters. React Native runs in a JavaScript environment that doesn’t have browser APIs. There’s no localStorage, no sessionStorage, no window. The state tools you know from web React still work, but the persistence layer is completely different — and if you don’t know what to reach for, you’ll either lose data between app launches or store the wrong thing the wrong way.

I’m going to walk through the four state layers every React Native app uses: local component state, global in-memory state with Context and Zustand, and persistent state with AsyncStorage. By the end you’ll know which tool belongs at which layer, and why the web patterns mostly transfer — except for one important part.

Series: Part 3 of 8.

On this page

Local state: nothing to unlearn

useState and useReducer work in React Native exactly the way they work in web React. No differences. No mobile-specific gotchas. If you’ve read React Hooks You’ll Actually Use Every Day, everything there applies directly.

The only thing that looks different is how you wire up controlled inputs. On the web you’d write onChange={(e) => setValue(e.target.value)}. In React Native, TextInput gives you the string directly via onChangeText:

import { useState } from 'react';
import { TextInput, View, Text } from 'react-native';

function EmailForm() {
  const [email, setEmail] = useState('');

  return (
    <View>
      <TextInput
        value={email}
        onChangeText={setEmail}
        placeholder="your@email.com"
        keyboardType="email-address"
      />
      <Text>Entered: {email}</Text>
    </View>
  );
}

onChangeText passes the string value directly, so you can often pass the setter function straight through without wrapping it. This isn’t a state management difference — it’s just a slightly different event API on the native component.

For anything that needs real logic — a counter with validation, a multi-step form, an accordion group — useReducer is the right call. The reducer pattern is identical to what you’d write for web React. You define actions, return new state, and React handles the rest.

Check this before moving on

  • You’re using onChangeText instead of onChange.target.value for text inputs
  • You know useState and useReducer work identically to web React
  • You have a real reason before reaching for global state — not just “prop drilling looks messy”

Context API works — but watch your provider order

Context for global state works in React Native the same way it works in web React: you create a context, wrap part of the tree with a provider, and any component below can consume it. The right use cases are the same — auth state, theme, locale, anything that changes slowly and needs to be readable anywhere in the app.

import { createContext, useContext, useState } from 'react';

interface AuthContextValue {
  userId: string | null;
}

const AuthContext = createContext<AuthContextValue>({ userId: null });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [userId, setUserId] = useState<string | null>(null);

  return (
    <AuthContext.Provider value={{ userId }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  return useContext(AuthContext);
}

The catch specific to React Native: if you’re using React Navigation (which most apps do), your app already has a NavigationContainer sitting at the top of the tree. That container uses context internally to pass navigation state to every screen. The question is where your own providers go relative to it.

The safe default is to wrap NavigationContainer with your own providers. This means every screen inside the navigator can read AuthContext, ThemeContext, and anything else you define:

export default function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <NavigationContainer>
          <RootNavigator />
        </NavigationContainer>
      </ThemeProvider>
    </AuthProvider>
  );
}

If a context only makes sense inside the navigator — something scoped to a single stack, for example — you can put that provider inside. But for app-wide state, keep providers outside. The rule is simple: wrap outward from whatever needs to be universally available.

The same re-render warning from React State Management: Context, Redux, and Zustand Compared applies here. Context re-renders every consumer when the value changes. For slowly-changing state like the current user that’s fine. For anything that updates on every keystroke or every frame, Context is the wrong tool — and the cost is steeper on a phone’s JavaScript thread than on a desktop browser.

Zustand for global state without the boilerplate

Zustand handles global state without requiring a Provider anywhere in your component tree. You create a store, and any component just calls the hook — no wrapping, no ordering concerns, no context nesting to get right.

import { create } from 'zustand';

interface CartStore {
  items: string[];
  addItem: (id: string) => void;
  clearCart: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (id) => set((state) => ({ items: [...state.items, id] })),
  clearCart: () => set({ items: [] }),
}));

In any component, you subscribe to exactly the slice you need:

function CartBadge() {
  const count = useCartStore((state) => state.items.length);
  return <Text>{count}</Text>;
}

function CheckoutButton() {
  const clearCart = useCartStore((state) => state.clearCart);
  return <Button title="Clear cart" onPress={clearCart} />;
}

That selector — (state) => state.items.length — is what makes Zustand practical on mobile. CartBadge only re-renders when items.length changes, not on every state update across the whole store. CheckoutButton subscribes to a function reference, which never changes, so it never re-renders from store updates at all.

On mobile, unnecessary re-renders are more expensive than on the web. React Native’s JavaScript thread handles both UI logic and business logic, so any avoidable work on the JS thread adds up — noticeably on mid-range Android devices. Keeping component subscriptions narrow is a small habit that pays off over time.

Zustand fits well for anything that’s global but doesn’t need to survive a full app restart: cart state, UI flags like open modals or active filters, user preferences set during a session, loading states shared across multiple screens.

AsyncStorage: the localStorage that isn’t

This is where React Native genuinely differs from web React. Mobile devices don’t have browser APIs. There’s no localStorage, no sessionStorage, no document.cookie. If you want state to survive a full app restart, you need a different mechanism.

The standard solution is @react-native-async-storage/async-storage — a community-maintained, asynchronous, unencrypted key-value store. According to React Native’s official security documentation, it’s “suitable for non-sensitive data, global variables, or state persistence across sessions.” On Android, it uses either RocksDB or SQLite as the backing store depending on what the device has available.

The defining characteristic is that it’s async. You can’t read from it at render time the way you’d read localStorage.getItem('key'). Everything is Promise-based:

import AsyncStorage from '@react-native-async-storage/async-storage';

// Write
await AsyncStorage.setItem('@app:theme', 'dark');

// Read — always async
const theme = await AsyncStorage.getItem('@app:theme');
// theme is either the string 'dark' or null if nothing was stored

Reading from AsyncStorage in a useEffect or an initialization function is fine. Reading it synchronously at the top level of a component — the way you might call localStorage.getItem — doesn’t work. You have to handle the async loading state.

For most apps, the cleanest approach is not to call AsyncStorage directly in components at all. Instead, use Zustand’s persist middleware with createJSONStorage. It handles the async hydration on startup and writes back to storage on every state change:

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface SettingsStore {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
}

const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'app-settings',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

On startup, the middleware reads the stored value from AsyncStorage and hydrates the store. On every state change, it serialises the updated state back. Your components just use the hook — they never need to know about the async storage underneath.

One thing that’s easy to overlook: AsyncStorage data is unencrypted. Anyone with physical access to the device and a way to read the app’s sandbox can read whatever you stored there. The React Native security docs are explicit about this: never store auth tokens, session secrets, or sensitive credentials in AsyncStorage. For sensitive data, use platform-specific secure storage — iOS Keychain or Android’s EncryptedSharedPreferences.

The two mistakes that catch web developers off guard

Treating AsyncStorage like localStorage. The most common early mistake is calling AsyncStorage.getItem at the top of a component and expecting a synchronous result. In a browser, localStorage.getItem returns the string immediately. In React Native, AsyncStorage.getItem returns a Promise. If you call it outside an async context, you get a pending Promise object — not the value. Every read needs await inside an async function or a useEffect that handles loading state.

Using Context for fast-updating state. This mistake trips up developers on both web and mobile, but the impact is worse on a phone. If you put form field values into a Context, every keystroke re-renders every Context consumer. On a desktop browser that causes a minor slowdown. On a mid-range Android device it can make the keyboard feel noticeably laggy, because the JavaScript thread is blocked processing re-renders while the user is still typing. The rule from the web still holds — Context is for slow-changing state — but the tolerance for getting it wrong is lower on mobile.

Matching the tool to the problem

What you’re storing Tool Why
Form fields, toggle state, modal open/close useState Component-local, no sharing needed
Complex local logic with multiple transitions useReducer Keeps update logic out of the component body
Auth state, theme, locale Context API Changes rarely; re-rendering all consumers is cheap
Cart, UI flags, shared session state Zustand Selector subscriptions, no Provider needed
Settings or data that survive app restarts Zustand + persist + AsyncStorage Automatic hydration and serialisation
Simple non-sensitive key-value across launches AsyncStorage directly Lightweight when you don’t need a store
Auth tokens, secrets, credentials iOS Keychain / Android Encrypted Prefs AsyncStorage is unencrypted — wrong choice here

Think in lifetimes, not just layers

The mental shift from web to mobile state isn’t really about the tools — it’s about recognising that state has three distinct lifetimes on a mobile device.

Component lifetime: state lives as long as the component is mounted. Navigate away from a screen and the component unmounts; that state is gone. useState and useReducer live here.

Session lifetime: state lives as long as the app is in memory. The user switches away and comes back, the state is still there. The user force-quits or the OS kills the app, it’s gone. Zustand and Context without persistence live here.

Persistent lifetime: state survives across app restarts until explicitly cleared. AsyncStorage lives here, as does Zustand + persist.

Most state bugs come from a mismatch — using session-lifetime state for something that should persist, or persisting something that should reset on logout. Before reaching for any tool, ask: how long should this state exist? The answer maps directly to a lifetime, which maps directly to a tool.

If you’re building your React Native app with TypeScript — which I’d strongly recommend — TypeScript for JavaScript Developers: Where to Start covers the TypeScript fundamentals you’ll lean on when typing your stores, context values, and reducer actions.

Sources