Expo React Native State Management: Modern Patterns and Best Practices
Learn effective state management patterns for Expo React Native apps using Context API, Zustand, and React Query for scalable applications.

Expo React Native State Management: Modern Patterns and Best Practices
State management is crucial for building scalable React Native applications. With Expo, you have access to modern React patterns and libraries that make state management efficient and maintainable. In this article, we'll explore different state management approaches for Expo React Native applications, from simple Context API to advanced patterns with Zustand and React Query.
Understanding State Management Needs
Before choosing a state management solution, understand your application's needs:
- Local State: Component-specific state (useState)
- Shared State: State shared across components (Context API, Zustand)
- Server State: Data fetched from APIs (React Query, SWR)
- Form State: Form inputs and validation (React Hook Form)
- Persistent State: State that survives app restarts (AsyncStorage, MMKV)
Context API for Simple Shared State
1. Basic Context Setup
For simple shared state, Context API works well:
// contexts/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadStoredUser();
}, []);
async function loadStoredUser() {
try {
const storedUser = await AsyncStorage.getItem('user');
if (storedUser) {
setUser(JSON.parse(storedUser));
}
} catch (error) {
console.error('Error loading user:', error);
} finally {
setIsLoading(false);
}
}
async function login(email: string, password: string) {
// API call
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const userData = await response.json();
setUser(userData);
await AsyncStorage.setItem('user', JSON.stringify(userData));
}
async function logout() {
setUser(null);
await AsyncStorage.removeItem('user');
}
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}2. Using Context in Components
// screens/ProfileScreen.tsx
import React from 'react';
import { View, Text, Button } from 'react-native';
import { useAuth } from '../contexts/AuthContext';
export function ProfileScreen() {
const { user, logout } = useAuth();
if (!user) {
return <Text>Not logged in</Text>;
}
return (
<View>
<Text>Welcome, {user.name}!</Text>
<Text>Email: {user.email}</Text>
<Button title="Logout" onPress={logout} />
</View>
);
}Zustand for Global State
Zustand provides a lightweight, scalable solution for global state:
1. Zustand Store Setup
// stores/userStore.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;
}
interface UserState {
user: User | null;
setUser: (user: User | null) => void;
clearUser: () => void;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}),
{
name: 'user-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);2. Complex Store with Actions
// stores/cartStore.ts
import create from 'zustand';
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>((set, get) => ({
items: [],
addItem: (item) => {
const existingItem = get().items.find((i) => i.id === item.id);
if (existingItem) {
set((state) => ({
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
}));
} else {
set((state) => ({
items: [...state.items, { ...item, quantity: 1 }],
}));
}
},
removeItem: (id) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}));
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().removeItem(id);
return;
}
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
}));
},
clearCart: () => {
set({ items: [] });
},
total: () => {
return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
}));3. Using Zustand in Components
// components/Cart.tsx
import React from 'react';
import { View, Text, FlatList, Button } from 'react-native';
import { useCartStore } from '../stores/cartStore';
export function Cart() {
const items = useCartStore((state) => state.items);
const updateQuantity = useCartStore((state) => state.updateQuantity);
const total = useCartStore((state) => state.total());
return (
<View>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View>
<Text>{item.name}</Text>
<Text>${item.price} x {item.quantity}</Text>
<Button
title="-"
onPress={() => updateQuantity(item.id, item.quantity - 1)}
/>
<Button
title="+"
onPress={() => updateQuantity(item.id, item.quantity + 1)}
/>
</View>
)}
/>
<Text>Total: ${total}</Text>
</View>
);
}React Query for Server State
React Query handles server state, caching, and synchronization:
1. React Query Setup
// App.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 1,
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
{/* Your app */}
</QueryClientProvider>
);
}2. Custom Hooks for API Calls
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
return response.json();
}
async function createUser(user: Omit<User, 'id'>): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return response.json();
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
// Invalidate and refetch users
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}3. Using React Query in Components
// screens/UsersScreen.tsx
import React from 'react';
import { View, Text, FlatList, ActivityIndicator, Button } from 'react-native';
import { useUsers, useCreateUser } from '../hooks/useUsers';
export function UsersScreen() {
const { data: users, isLoading, error } = useUsers();
const createUserMutation = useCreateUser();
if (isLoading) {
return <ActivityIndicator />;
}
if (error) {
return <Text>Error: {error.message}</Text>;
}
const handleCreateUser = () => {
createUserMutation.mutate({
name: 'New User',
email: 'user@example.com',
});
};
return (
<View>
<Button
title="Add User"
onPress={handleCreateUser}
disabled={createUserMutation.isPending}
/>
<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View>
<Text>{item.name}</Text>
<Text>{item.email}</Text>
</View>
)}
/>
</View>
);
}Combining State Management Solutions
1. Hybrid Approach
Combine different solutions for different needs:
// App.tsx
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{/* Your app */}
</AuthProvider>
</QueryClientProvider>
);
}2. Form State with React Hook Form
// components/LoginForm.tsx
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { TextInput, Button, View, Text } from 'react-native';
import { useAuth } from '../contexts/AuthContext';
interface LoginFormData {
email: string;
password: string;
}
export function LoginForm() {
const { login } = useAuth();
const { control, handleSubmit, formState: { errors } } = useForm<LoginFormData>();
const onSubmit = async (data: LoginFormData) => {
await login(data.email, data.password);
};
return (
<View>
<Controller
control={control}
name="email"
rules={{
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
}}
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
/>
)}
/>
{errors.email && <Text>{errors.email.message}</Text>}
<Controller
control={control}
name="password"
rules={{
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
}}
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="Password"
secureTextEntry
/>
)}
/>
{errors.password && <Text>{errors.password.message}</Text>}
<Button title="Login" onPress={handleSubmit(onSubmit)} />
</View>
);
}Performance Optimization
1. Memoization
Use React.memo and useMemo for expensive computations:
// components/ExpensiveComponent.tsx
import React, { useMemo } from 'react';
import { View, Text } from 'react-native';
interface ExpensiveComponentProps {
items: number[];
}
export const ExpensiveComponent = React.memo(({ items }: ExpensiveComponentProps) => {
const expensiveValue = useMemo(() => {
return items.reduce((sum, item) => sum + item * 2, 0);
}, [items]);
return (
<View>
<Text>Result: {expensiveValue}</Text>
</View>
);
});2. Selective Subscriptions
With Zustand, subscribe only to needed state:
// ✅ Good: Subscribe only to needed state
const userName = useUserStore((state) => state.user?.name);
// ❌ Bad: Subscribe to entire store
const { user } = useUserStore();Best Practices Summary
- Use useState for local component state
- Use Context API for simple shared state
- Use Zustand for complex global state
- Use React Query for server state and caching
- Use React Hook Form for form state
- Persist important state with AsyncStorage or MMKV
- Memoize expensive computations with useMemo
- Selectively subscribe to state changes
- Combine solutions for different needs
- Keep state normalized and avoid duplication
Conclusion
Effective state management in Expo React Native requires choosing the right tool for each use case. Context API works for simple shared state, Zustand excels for complex global state, and React Query handles server state beautifully. By combining these tools appropriately and following best practices, you can build scalable, maintainable React Native applications.
References
Want more insights?
Subscribe to our newsletter or follow us for more updates on software development and team scaling.
Contact Us