State Management in React Native: Patterns Compared
Compare React Native state management libraries by re-render cost and hydration behavior on low-end Android — Zustand, Redux Toolkit, Jotai, and Context explained.

React Native state management breaks in ways web React doesn't prepare you for. A Context provider that's "fine" in a browser can force a full re-render tree on a mid-range Android device and drop frames during a scroll gesture. The same pattern that felt acceptable in a web prototype becomes the reason your mobile app feels sluggish on the devices most of your users actually own.
The choice between Zustand, Redux Toolkit, Jotai, and plain Context isn't primarily a developer-experience decision. It's a rendering and memory decision. Each library makes different trade-offs around subscription granularity, store hydration from async storage, and how aggressively it triggers re-renders when a slice of state changes.
At Laxaar, we've shipped React Native apps for clients across fintech, logistics, and consumer products. We've profiled all four of these patterns against real device constraints, and this post is the comparison we wish existed before we made some expensive refactoring decisions.
What you'll learn
- Why re-render cost differs between web and React Native
- Context API: when it's enough and when it isn't
- Zustand: lightweight atoms with manual subscriptions
- Redux Toolkit: structured state for complex apps
- Jotai: fine-grained atomic state
- Hydration from AsyncStorage compared
- Pattern comparison table
- Frequently Asked Questions
Why re-render cost differs between web and React Native
React Native's rendering pipeline is not the same as React DOM's. On the web, browsers batch DOM mutations and the layout engine is fast. React Native renders to native views via a bridge (or, with the New Architecture, via JSI), and native layout passes on Android have measurable overhead per view update.
A component tree re-rendering unnecessarily on web might cost 2-3ms in practice. On a Snapdragon 450 — which represents a large share of Android devices in markets like India, Southeast Asia, and Latin America — the same unnecessary re-render can cost 15-25ms. At 16ms per frame budget, one avoidable re-render breaks frame pacing.
This is the lens that matters. Developer-experience polish, bundle size differences between libraries, and TypeScript ergonomics are all real factors. Re-render granularity, though, is the one that shows up in profiler traces as dropped frames.
The second factor is hydration. React Native apps almost always persist state to AsyncStorage, MMKV, or SQLite. How a library rehydrates that persisted state on app launch, and whether it blocks the first render, determines perceived startup time.
Context API: when it's enough and when it isn't
React Context is a built-in mechanism for passing values down a component tree without prop drilling. It's the right choice for state that changes infrequently and is consumed by many components: authentication state, theme, locale.
The problem is subscription granularity. When a Context value changes, every component that calls useContext for that context re-renders, regardless of whether the specific slice of data it cares about changed. There's no per-key subscription.
// This pattern re-renders ALL consumers when any field changes
const AppContext = React.createContext<AppState | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = React.useState<AppState>({
user: null,
cart: [],
notifications: 0,
});
return (
<AppContext.Provider value={{ state, setState }}>
{children}
</AppContext.Provider>
);
}
// CartBadge re-renders whenever user or notifications change — even though it only cares about cart
function CartBadge() {
const { state } = React.useContext(AppContext)!;
return <Text>{state.cart.length}</Text>;
}
For a shopping cart badge, this means every notification update triggers a re-render of the badge. In isolation, that's harmless. Across a complex screen with dozens of Context consumers, it accumulates into dropped frames.
Context works well for truly global, rarely-changing state. It falls short once your state updates frequently or your component tree is deep enough that a single context change cascades through 20+ components.
Zustand: lightweight atoms with manual subscriptions
Zustand is a minimal state library built on a publish-subscribe model. A store is a function that returns a state object and a set of actions. Components subscribe to specific slices of that store via selector functions, and they only re-render when the selected value changes.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface CartStore {
items: CartItem[];
total: number;
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => {
const items = get().items.filter((i) => i.id !== id);
set({ items, total: items.reduce((sum, i) => sum + i.price, 0) });
},
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// CartBadge only re-renders when items.length changes
function CartBadge() {
const count = useCartStore((state) => state.items.length);
return <Text>{count}</Text>;
}
The selector (state) => state.items.length means CartBadge re-renders only when the cart count changes. A notification update, a user profile change, or any other store update won't touch it.
Zustand's persist middleware handles AsyncStorage hydration out of the box. By default, hydration is async: components render with an empty/default state first, then update when the store rehydrates. For most apps that's fine. For screens that must show persisted data immediately, you can use MMKV with synchronous reads as the storage adapter.
The trade-off: Zustand's simplicity is also a constraint. There's no enforced structure for complex state, no standardized pattern for normalized data, and no built-in tooling comparable to Redux DevTools (though a Zustand DevTools middleware exists). For a small-to-medium app, these aren't real problems. For a large team working on a complex app with many developers, the lack of guardrails can lead to inconsistent store patterns across features.
Redux Toolkit: structured state for complex apps
Redux Toolkit (RTK) is the opinionated, modern way to use Redux. It eliminates most of Redux's historical boilerplate through createSlice, createAsyncThunk, and createEntityAdapter.
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
interface CartState {
items: Record<string, CartItem>;
ids: string[];
status: 'idle' | 'loading' | 'error';
}
const initialState: CartState = { items: {}, ids: [], status: 'idle' };
export const loadCart = createAsyncThunk('cart/load', async () => {
const raw = await AsyncStorage.getItem('cart');
return raw ? JSON.parse(raw) : { items: {}, ids: [] };
});
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addItem(state, action: PayloadAction<CartItem>) {
state.items[action.payload.id] = action.payload;
state.ids.push(action.payload.id);
},
removeItem(state, action: PayloadAction<string>) {
delete state.items[action.payload];
state.ids = state.ids.filter((id) => id !== action.payload);
},
},
extraReducers: (builder) => {
builder.addCase(loadCart.fulfilled, (state, action) => {
state.items = action.payload.items;
state.ids = action.payload.ids;
});
},
});
RTK's useSelector works like Zustand's selector pattern — components only re-render when their selected value changes. RTK Query, bundled with Redux Toolkit, handles server-state fetching and caching in a way that avoids duplicating that logic across slices.
The honest case against RTK in React Native: it's heavy for small apps. The setup involves a store configuration, provider wrapping, slices per feature, and separate async thunks. For a five-screen app with one developer, that structure costs more in setup time than it saves. For a fifteen-screen app with a team of four or more, the enforced structure pays back quickly.
RTK also doesn't have first-class AsyncStorage persistence built in. You'll reach for redux-persist, which introduces its own configuration layer and has historically had quirks around rehydration timing that cause brief flickers on Android if not handled carefully.
Jotai: fine-grained atomic state
Jotai models state as small, composable atoms. Each atom holds a single piece of state. Components subscribe to specific atoms, and Jotai only re-renders components whose atoms changed.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Primitive atoms
const cartItemsAtom = atomWithStorage<CartItem[]>(
'cart-items',
[],
{
getItem: async (key) => {
const val = await AsyncStorage.getItem(key);
return val ? JSON.parse(val) : [];
},
setItem: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
removeItem: async (key) => {
await AsyncStorage.removeItem(key);
},
}
);
// Derived atom — recomputed only when cartItemsAtom changes
const cartCountAtom = atom((get) => get(cartItemsAtom).length);
const cartTotalAtom = atom((get) =>
get(cartItemsAtom).reduce((sum, item) => sum + item.price, 0)
);
// CartBadge re-renders only when cart count changes — zero wasted renders
function CartBadge() {
const count = useAtomValue(cartCountAtom);
return <Text>{count}</Text>;
}
Jotai's derived atoms are the killer feature for React Native. cartCountAtom and cartTotalAtom recompute only when cartItemsAtom changes, and components that subscribe to derived atoms only re-render when the derived value itself changes. You get granular subscriptions with minimal boilerplate.
The atomWithStorage utility from jotai/utils handles async persistence, but async hydration means the first render shows the default value before storage loads. Jotai doesn't have a built-in mechanism to block the tree until hydration completes — you handle that with a loading gate in your root component.
Our opinionated take: Jotai is the best fit for apps with many small, frequently-updated state slices, like real-time feeds, live search, or form-heavy UIs. For apps where the state structure is relational and team size is large, the lack of enforced structure can lead to atom sprawl.
Hydration from AsyncStorage compared
Hydration behavior on app launch deserves its own focus because it directly affects the "time to interactive" users experience.
| Library | Hydration mechanism | Blocks first render? | Typical hydration time (cold start) |
|---|---|---|---|
| Context | Manual — you fetch and set state in a useEffect | No | Depends entirely on your implementation |
| Zustand + persist | Async by default; MMKV adapter supports sync | No (async) / Yes (MMKV sync) | 20-80ms async; under 5ms with MMKV |
| Redux Toolkit + redux-persist | Async via REHYDRATE action; requires PersistGate | Yes (with PersistGate) | 30-120ms; longer on large state trees |
| Jotai + atomWithStorage | Async per atom; no global gate | No | 20-60ms per atom |
PersistGate from redux-persist can block the entire React tree from rendering until rehydration completes. That eliminates the flicker of seeing default state, but it delays your first paint. On a cold start on a low-end Android device, 50-100ms of blank screen is noticeable.
Zustand with MMKV (a synchronous key-value store backed by native code) sidesteps this entirely. The store hydrates synchronously before the first render, so there's no default-state flash and no gate needed. It's the approach the Laxaar team reaches for when perceived startup time is a product requirement.
Pattern comparison table
| Dimension | Context | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|---|
| Re-render granularity | Whole context on any change | Per-selector (good) | Per-selector (good) | Per-atom (best) |
| Setup overhead | None | Low | High | Low |
| Async persistence | Manual | Built-in (persist middleware) | redux-persist required | atomWithStorage utility |
| Sync persistence | Manual | MMKV adapter | Manual | MMKV adapter |
| DevTools support | React DevTools only | Zustand DevTools middleware | Redux DevTools (excellent) | Jotai DevTools |
| Team scale fit | Small teams, simple state | Small to medium | Medium to large | Any size |
| TypeScript experience | Good | Excellent | Excellent | Excellent |
| Bundle size (minified) | 0kb (built-in) | ~3kb | ~12kb | ~3kb |
Frequently Asked Questions
Which library has the best re-render performance on low-end Android?
Jotai's per-atom subscription model produces the fewest unnecessary re-renders because each component subscribes to the smallest possible unit of state. Zustand with granular selectors is a close second. Redux Toolkit with useSelector is comparable to Zustand. Context re-renders every consumer on every change and should be reserved for state that changes infrequently.
Should we use MMKV instead of AsyncStorage for persistence?
For most consumer apps where startup time matters: yes. MMKV reads are synchronous and typically 10-100x faster than AsyncStorage reads. Both Zustand and Jotai have MMKV storage adapters. The trade-off is that MMKV is a native module requiring a native build step, which means it doesn't run in Expo Go without a custom dev client. For Expo-managed workflow, AsyncStorage remains the easier default.
Is Redux Toolkit still worth it in 2026?
For large teams building complex apps with relational data, yes. RTK's enforced slice structure, createEntityAdapter for normalized state, and RTK Query for server state cover patterns that Zustand and Jotai leave to convention. The setup cost is real but it prevents the state-management sprawl that grows into a maintenance problem on large codebases. For apps with under ten screens or solo developers, it's too much ceremony.
How do we handle state that mixes local UI state and global app state?
Keep them separate. Local UI state (is this modal open, what's the input value) belongs in useState or useReducer inside the component. Global app state (user, cart, settings) belongs in your chosen library. Pushing local UI state into Zustand or Redux is a common mistake that inflates your global store and makes unrelated components subscribe to irrelevant updates. The rule we follow at Laxaar: if only one component needs it, it stays local.
Can we mix Zustand for global state and Context for theme/auth?
Absolutely. Context is the right tool for truly static or near-static values like theme tokens and auth session. Zustand, Jotai, or RTK handle the dynamic state that changes with user interaction. This hybrid approach is what we'd recommend for most new projects, since it avoids adding a library dependency for state that Context handles correctly.
Building a React Native app and not sure which state architecture fits your scale and device targets? The Laxaar mobile development team can audit your current setup or help you architect a new one from scratch. Reach out via our contact page or explore what we've shipped in our portfolio.
Working on something like this?
Get a fixed scope, timeline, and price within one business day — no obligation.


