State Management in React Native: Zustand vs Redux Toolkit
A practical comparison of Zustand and Redux Toolkit for React Native apps — when to use each, real-world patterns, async actions, and persistence with MMKV storage.
State management in React Native follows the same principles as React on the web, but with extra concerns: offline support, background sync, and persistence across app restarts. Choosing the right tool saves you from major refactoring down the line.
This guide compares Zustand and Redux Toolkit (RTK) in the context of React Native apps, with practical patterns for real-world scenarios.
The Landscape in 2026
For most React Native apps, the choice comes down to:
- Zustand — minimal, fast, and boilerplate-free
- Redux Toolkit — structured, powerful middleware ecosystem, better for complex apps
- React Query / TanStack Query — specifically for server state (API data)
- Context API — fine for small apps, doesn't scale
The common mistake is treating all state the same. Most apps have two very different types:
- Server state: data from your API (posts, users, products) — use React Query
- Client state: UI state, auth tokens, user preferences — use Zustand or RTK
Zustand: The Pragmatic Choice
Zustand requires almost no boilerplate and feels like writing regular JavaScript:
npm install zustand// store/authStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface User {
id: string
email: string
name: string
token: string
}
interface AuthState {
user: User | null
isAuthenticated: boolean
login: (user: User) => void
logout: () => void
updateUser: (updates: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
)// store/cartStore.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
addItem: (item: Omit<CartItem, 'quantity'>) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartState>()(
immer((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find(i => i.id === item.id)
if (existing) {
existing.quantity += 1
} else {
state.items.push({ ...item, quantity: 1 })
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter(i => i.id !== id)
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find(i => i.id === id)
if (item) item.quantity = quantity
}),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}))
)Using stores in components:
// screens/ProfileScreen.tsx
import { useAuthStore } from '@/store/authStore'
export default function ProfileScreen() {
const { user, logout } = useAuthStore()
if (!user) return null
return (
<View>
<Text>{user.name}</Text>
<Text>{user.email}</Text>
<Button title="Sign Out" onPress={logout} />
</View>
)
}Redux Toolkit: For Complex Apps
RTK shines when you need:
- Complex middleware (logging, analytics, crash reporting)
- Time-travel debugging with Redux DevTools
- Very large teams where structure and convention matter
- Complex async flows with
createAsyncThunk
npm install @reduxjs/toolkit react-redux// store/slices/authSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
interface AuthState {
user: User | null
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
export const loginThunk = createAsyncThunk(
'auth/login',
async (credentials: { email: string; password: string }, { rejectWithValue }) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) {
const error = await response.json()
return rejectWithValue(error.message)
}
return await response.json()
} catch (error) {
return rejectWithValue('Network error')
}
}
)
const authSlice = createSlice({
name: 'auth',
initialState: { user: null, status: 'idle', error: null } as AuthState,
reducers: {
logout: (state) => {
state.user = null
state.status = 'idle'
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(loginThunk.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(loginThunk.fulfilled, (state, action: PayloadAction<User>) => {
state.status = 'succeeded'
state.user = action.payload
})
.addCase(loginThunk.rejected, (state, action) => {
state.status = 'failed'
state.error = action.payload as string
})
},
})
export const { logout } = authSlice.actions
export default authSlice.reducerServer State: Use TanStack Query
For API data, neither Zustand nor Redux should be your first choice:
npm install @tanstack/react-query// hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts')
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
},
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
})
}
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (post: { title: string; content: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
})
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}Persistence with MMKV (Fast Storage)
AsyncStorage works but is slow for large datasets. MMKV is 10x faster:
npm install react-native-mmkv// lib/storage.ts
import { MMKV } from 'react-native-mmkv'
import { StateStorage } from 'zustand/middleware'
const storage = new MMKV()
export const zustandMMKVStorage: StateStorage = {
getItem: (name) => storage.getString(name) ?? null,
setItem: (name, value) => storage.set(name, value),
removeItem: (name) => storage.delete(name),
}
// Use in Zustand persist middleware:
// storage: createJSONStorage(() => zustandMMKVStorage)Which to Choose
| Scenario | Recommendation |
|---|---|
| Simple app, 1-2 devs | Zustand |
| API data fetching | TanStack Query |
| Complex async flows | RTK |
| Large team, enterprise | RTK |
| Need DevTools debugging | RTK |
| Offline-first app | Zustand + TanStack Query offline |
Key Takeaways
- Split server state (API data) from client state — use different tools for each
- Zustand is simpler and faster to write; RTK is more structured and better for large teams
- TanStack Query handles server state better than either Zustand or Redux
- Use MMKV instead of AsyncStorage for performance-sensitive persistence
- The Zustand
immermiddleware makes complex state mutations simple and safe - Persist only what you need — persisting too much state causes slow app startup

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.