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.
For most React Native apps, the choice comes down to:
The common mistake is treating all state the same. Most apps have two very different types:
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>
)
}RTK shines when you need:
createAsyncThunknpm 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.reducerFor 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'] })
},
})
}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)| 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 |
immer middleware makes complex state mutations simple and safe