State management is one of those topics that feels solved until you're three months into a project and your codebase looks like a filing cabinet that caught fire. Redux dominated the conversation for years. Then everyone burned out on boilerplate. Context API stepped in as the "maybe we don't need anything" answer — until apps got complex and re-renders became a tax.
Today the community has largely settled on two featherweight libraries: Zustand and Jotai. They share the same creator (Daishi Kato) but solve the problem from opposite directions. Choosing between them — or neither — is worth thinking through before you start.
Why state management still matters
React Native apps share the same component model as React on the web, so they inherit the same state challenges. But mobile adds its own wrinkles: navigation stacks that keep components mounted, aggressive re-render sensitivity on lower-end Android devices, background/foreground lifecycle events, and state that often needs to survive app restarts.
useState handles local UI state beautifully. Problems start when you need to share state across screens, coordinate async data, or avoid prop-drilling through a five-level navigator hierarchy. This is where most teams reach for a library — and where many reach for the wrong one.
useState is probably enough. If it spans multiple screens or needs to persist, you need something more.
Think of it as a global store you can hook into anywhere, with zero ceremony.
Zustand's mental model is simple: you define a store as a function that returns state and actions, then consume pieces of it with a hook. That's it. No providers. No reducers. No action creators. The API surface is tiny enough to fit on a Post-it note.
Creating a store
import { create } from 'zustand'
type CartStore = {
items: CartItem[]
total: number
addItem: (item: CartItem) => void
removeItem: (id: string) => void
clearCart: () => void
}
export const useCartStore = create<CartStore>()(set => ({
items: [],
total: 0,
addItem: (item) =>
set((state) => {
const items = [...state.items, item]
return {
items,
total: items.reduce((sum, i) => sum + i.price, 0)
}
}),
removeItem: (id) =>
set((state) => {
const items = state.items.filter((i) => i.id !== id)
return { items, total: items.reduce((sum, i) => sum + i.price, 0) }
}),
clearCart: () => set({ items: [], total: 0 }),
}))
// No Provider needed — just import and use
store/cart.ts
Consuming it in a component
import { useCartStore } from '../store/cart'
function CartBadge() {
// Only re-renders when `items.length` changes, not any other store key
const count = useCartStore((state) => state.items.length)
return <Badge label={count} />
}
function CartScreen() {
const { items, total, removeItem, clearCart } = useCartStore()
return (
<View>
{items.map((item) => (
<CartRow
key={item.id}
item={item}
onRemove={() => removeItem(item.id)}
/>
))}
<Text>Total: ${total}</Text>
<Button title="Clear" onPress={clearCart} />
</View>
)
}
CartScreen.tsx
Notice the selector pattern in CartBadge. When you pass a selector to the hook, Zustand only re-renders your component when that specific slice changes. This is critical for performance — without selectors, every component consuming the store re-renders on any state change.
Persisting state across app restarts
One killer feature for mobile: Zustand's persist middleware works seamlessly with @react-native-async-storage/async-storage. Your store survives kills and restarts with five extra lines:
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
export const useCartStore = create<CartStore>()(
persist(
(set) => ({ /* ...same as before */ }),
{
name: 'cart-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
)
store/cart.ts
persist, be deliberate about what you persist. Serializing large lists or sensitive user data to AsyncStorage has size limits and security implications. Consider persisting only the minimal fields you actually need across restarts.
Think of it as useState that can be shared between any component, with composable derived state.
Where Zustand thinks in stores, Jotai thinks in atoms. An atom is just a piece of state — defined outside any component, shared globally, composable with other atoms. It feels like useState but without the confinement of a single component tree.
Defining and using atoms
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// Primitive atoms — just data
export const itemsAtom = atom<CartItem[]>([])
export const userAtom = atom<User | null>(null)
// Derived atom — computed from other atoms, auto-updates
export const totalAtom = atom((get) =>
get(itemsAtom).reduce((sum, item) => sum + item.price, 0)
)
export const itemCountAtom = atom((get) => get(itemsAtom).length)
// Write atom — an action as an atom
export const addItemAtom = atom(
null,
(get, set, item: CartItem) => {
set(itemsAtom, [...get(itemsAtom), item])
}
)
atoms/cart.ts
function CartBadge() {
// Only subscribes to itemCountAtom — fine-grained reactivity
const count = useAtomValue(itemCountAtom)
return <Badge label={count} />
}
function CartTotal() {
const total = useAtomValue(totalAtom)
return <Text>${total.toFixed(2)}</Text>
}
function AddToCartButton({ item }: { item: CartItem }) {
const addItem = useSetAtom(addItemAtom)
return <Button onPress={() => addItem(item)} title="Add to cart" />
}
components/Cart.tsx
The granularity here is Jotai's superpower. CartBadge only re-renders when item count changes. CartTotal only re-renders when the total changes. You get precise subscriptions for free, just by composing atoms — no selector boilerplate required.
Jotai's atoms scale the same way good architecture does: you add more, compose them, and complexity stays local.
Async atoms with Suspense
Jotai's async atoms pair naturally with React Suspense. An atom that returns a Promise is automatically suspense-compatible:
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
// Async atom using jotai-tanstack-query (recommended pairing)
const userIdAtom = atom('user_123')
const userQueryAtom = atomWithQuery((get) => ({
queryKey: ['user', get(userIdAtom)],
queryFn: async () => {
const res = await fetch(`/api/users/${get(userIdAtom)}`)
return res.json()
},
}))
// In your component — handled by Suspense boundary above
function UserProfile() {
const { data: user } = useAtomValue(userQueryAtom)
return <Text>{user.name}</Text>
}
atoms/user.ts
When to skip a library entirely
Here's the take nobody leads with: most apps don't need a global state library. Not initially, and sometimes not ever. Adding Zustand or Jotai to a small app is like buying a filing cabinet for a Post-it collection.
useState + prop drilling (seriously)
For apps with one or two screens, or state that stays within a feature, lift state up and pass it down. This isn't a compromise — it's explicit, type-safe, and completely predictable. Refactoring later is easier when state ownership is obvious.
React Context — but only for the right things
Context is often blamed for performance problems it didn't cause. The real issue is using Context for high-frequency state. For low-frequency global values — theme, locale, auth status, feature flags — Context is perfectly appropriate and requires no dependencies.
// Good use of Context: infrequently-changing auth state
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
// Changes once: on login/logout. Context is perfect here.
return (
<AuthContext.Provider value={{ user, loading, setUser }}>
{children}
</AuthContext.Provider>
)
}
context/auth.tsx
React Query / TanStack Query — for server state
A significant chunk of what teams reach for global state libraries to solve is actually server state — data fetched from an API. If that's your problem, a library like TanStack Query solves it far better than Zustand or Jotai alone: caching, background refetching, pagination, optimistic updates, loading/error states — all handled for you.
In fact, TanStack Query pairs exceptionally well with both Zustand and Jotai: use the query library for remote data, and your state library for the thin layer of local UI state that remains.
Zustand vs Jotai at a glance
| Dimension | Zustand | Jotai |
|---|---|---|
| Mental model | One store, slice by selector | Many atoms, compose freely |
| Boilerplate | Minimal (one create call) | Near-zero (just atom()) |
| Re-renders | Controlled with selectors | Automatic, per-atom |
| TypeScript | Excellent | Excellent |
| Async state | Handle manually or with middleware | Native async atoms + Suspense |
| Persistence | Built-in persist middleware | atomWithStorage via jotai/utils |
| DevTools | Redux DevTools via middleware | Jotai DevTools (limited) |
| Best for | Domain stores, feature slices | Fine-grained, derived state |
| Bundle size | ~2kb gzipped | ~2kb gzipped |
So, which do you pick?
→ Use Zustand if…
- You think in stores (cart, auth, settings)
- You need Redux DevTools for debugging
- You want built-in persist middleware
- Your team knows Redux; this transition is natural
- You want one place for all state in a feature
→ Use Jotai if…
- State is scattered and highly derived
- You want Suspense-native async state
- You're tired of writing selectors
- You're pairing it with TanStack Query
- Fine-grained reactivity matters
→ Use Context if…
- State changes rarely (theme, auth)
- You want zero dependencies
- App is small, one or two screens
- The value is essentially config
→ Skip everything if…
- State is truly local to one screen
- All your "global" state is server state (use TanStack Query)
- You're still in early prototype phase
- Prop drilling is three levels or fewer
The approach that actually works
Start with useState. When you find yourself lifting state up past two or three components, evaluate whether it's server state (reach for TanStack Query) or client state (reach for Zustand or Jotai). Pick Zustand if you think in stores; Jotai if you think in atoms.
The teams who get this right aren't the ones who pick the "correct" library — they're the ones who wait until they actually feel the pain before adding a dependency. State management libraries don't prevent complexity. They just give it a better address.
The best state management solution is the one you don't need yet.
Both Zustand and Jotai are small, well-maintained, and TypeScript-first. You can't go wrong with either. But you can go wrong by reaching for one on day one of a project that needs neither.