State Management
React Native state management — Redux Toolkit, Zustand, Jotai, TanStack Query selection and patterns
State Management
There is no single right answer for state management in React Native. The right answer comes from first separating what kind of state you have, then picking the simplest tool that handles it.
State Categories
| Category | Examples | Recommended Tool |
|---|---|---|
| Local UI state | A modal open/close, input draft | useState / useReducer |
| Cross-screen client state | Selected tab, cart, theme | zustand / Redux Toolkit / Jotai |
| Server state | User profile from API, paginated lists | TanStack Query / SWR / RTK Query |
| Form state | Multi-field forms with validation | react-hook-form |
| URL state | Active screen, params | React Navigation params |
| Persisted state | Login token, preferences | MMKV + state-library middleware |
Anti-pattern: dumping everything into one global store. A global store should hold what genuinely crosses screens. Local UI state in a global store is harder to reason about and harder to delete. Use the right scope.
Server State Is Not Like Client State
A common mistake is treating "data from the backend" the same as "the cart count". They behave differently:
| Concern | Client state | Server state |
|---|---|---|
| Source of truth | The app | The server |
| Can go stale | No | Yes |
| Multiple components query it | Sometimes | Always |
| Needs cache invalidation | No | Yes |
| Needs retry / background refetch | No | Yes |
Pick a server-state library (TanStack Query / SWR / RTK Query) for anything fetched from a backend. Then your client-state library only needs to handle the smaller, easier slice.
TanStack Query for Server State
// app/providers/Query.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status < 500) return false;
return failureCount < 3;
},
},
},
});
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// features/order/hooks/useOrders.ts
export function useOrders(status?: string) {
return useQuery({
queryKey: ['orders', { status }],
queryFn: ({ signal }) => api.getOrders({ status }, signal),
});
}
// features/order/hooks/useUpdateOrder.ts
export function useUpdateOrder() {
const qc = useQueryClient();
return useMutation({
mutationFn: api.updateOrder,
onSuccess: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});
}Offline + Background Refetch
// Refetch on app foreground
import { focusManager } from '@tanstack/react-query';
import { AppState } from 'react-native';
AppState.addEventListener('change', (status) => {
focusManager.setFocused(status === 'active');
});
// Refetch when network returns
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';
NetInfo.addEventListener((state) => {
onlineManager.setOnline(!!state.isConnected);
});Persist the Cache
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
const persister = createSyncStoragePersister({
storage: {
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
},
});
persistQueryClient({ queryClient, persister, maxAge: 24 * 60 * 60 * 1000 });Comparing Client-State Libraries
| Library | Boilerplate | DevTools | TS ergonomics | Bundle | Recommended For |
|---|---|---|---|---|---|
useState / Context | None | None | Native | 0 | Trivial cross-Widget sharing |
| Zustand | Minimal | Flipper plugin | Excellent | ~1 KB | Default for most projects |
| Jotai | Atom-per-piece | Yes | Excellent | ~3 KB | Apps where derived/atomic state dominates |
| Redux Toolkit | Moderate | Best-in-class | Good | ~12 KB | Large apps, strict patterns, time-travel debug |
| MobX | Decorator-heavy | Yes | OK | ~16 KB | Teams from OO backgrounds |
| XState | High | Yes | Excellent | ~16 KB | Complex flows expressed as state machines |
Zustand: The Default
// features/cart/store.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
const mmkvStorage = {
getItem: (k: string) => storage.getString(k) ?? null,
setItem: (k: string, v: string) => storage.set(k, v),
removeItem: (k: string) => storage.delete(k),
};
interface CartState {
items: CartItem[];
add: (item: CartItem) => void;
remove: (id: string) => void;
total: () => number;
}
export const useCart = create<CartState>()(
persist(
(set, get) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}),
{ name: 'cart', storage: createJSONStorage(() => mmkvStorage) },
),
);
// Component usage
const items = useCart((s) => s.items); // resubscribe only when items change
const add = useCart((s) => s.add); // function ref is stableSlices Pattern
// Splits a large store into composed slices
type UserSlice = { user: User | null; signIn: (u: User) => void };
type CartSlice = { items: CartItem[]; add: (i: CartItem) => void };
const createUserSlice: StateCreator<UserSlice & CartSlice, [], [], UserSlice> = (set) => ({
user: null,
signIn: (user) => set({ user }),
});
const createCartSlice: StateCreator<UserSlice & CartSlice, [], [], CartSlice> = (set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
});
export const useStore = create<UserSlice & CartSlice>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));Redux Toolkit: For Stricter Architecture
Choose RTK when:
- Team is large and you need enforced conventions.
- You want time-travel debugging in production triage.
- You're standardizing across multiple apps with a shared playbook.
// features/cart/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] as CartItem[] },
reducers: {
added: (state, action: PayloadAction<CartItem>) => {
state.items.push(action.payload);
},
removed: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const { added, removed } = cartSlice.actions;
export default cartSlice.reducer;RTK Query Combines Both
If you're already on Redux, RTK Query gives you server-state caching too — fewer libraries to onboard.
export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Order'],
endpoints: (build) => ({
getOrders: build.query<Order[], void>({
query: () => 'orders',
providesTags: ['Order'],
}),
updateOrder: build.mutation<Order, Partial<Order> & { id: string }>({
query: ({ id, ...patch }) => ({ url: `orders/${id}`, method: 'PATCH', body: patch }),
invalidatesTags: ['Order'],
}),
}),
});Jotai: Atomic State
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubled] = useAtom(doubledAtom);
return <Text onPress={() => setCount(count + 1)}>{count} / {doubled}</Text>;
}Strong fit when state naturally factors into many small, derived pieces (Figma-style canvas tools, complex forms).
Forms: Don't Use Global State
// Bad: every keystroke updates the global store, triggering subscribers
const setDraft = useStore((s) => s.setDraft);
<TextInput onChangeText={setDraft} />
// Good: react-hook-form keeps form state local and uncontrolled
const { control, handleSubmit } = useForm<FormData>();Patterns to Avoid
Prop Drilling Five Levels Deep
Lift state to the nearest common ancestor, then either pass props or extract a Context for that subtree.
One Massive Store
Split by domain (useUser, useCart, useNotifications). Independent updates, independent persistence, easier deletion.
Direct Mutation Without Immer
RTK ships with Immer; raw Redux requires immutable updates. Direct mutation is a frequent source of "state changes but UI doesn't update".
Async Work in Reducers
Async lives in thunks (RTK), mutations (TanStack Query), or store actions (Zustand). Reducers must be pure.
Decision Tree
Need it?
├── No → useState, you're done
└── Yes
├── From the server? → TanStack Query (default) / RTK Query (if on RTK)
├── Form? → react-hook-form
├── Single screen? → useState / useReducer
└── Cross-screen?
├── Small team / project → Zustand
├── Derived & atomic state heavy → Jotai
└── Large team / strict patterns → Redux Toolkit