State Management and Backend API Integration
When your app has only two or three pages, you can use React's built-in useState and props to pass data. But as the app grows larger:
- The "Home" page needs to display the user's avatar.
- The "Profile" page allows modifying the avatar.
- The "Sidebar" also needs to display the avatar.
If you still rely on passing props layer by layer, you'll encounter the infamous "Props Drilling" problem. This is when we need Global State Management.
1. Ditch Redux, Embrace Zustand!
In the past, Redux was the go-to choice for React developers. However, Redux setup is extremely cumbersomeโyou need to write Actions, Reducers, and Dispatches, which is overkill for small projects.
In recent years, Zustand (German for "state") has become the favorite of modern React/RN developers. It's extremely lightweight, doesn't require wrapping the entire app in <Provider>, and its syntax is as simple as using a regular Hook!
Install Zustand:
npm install zustand
Create a Store
Create a store/useAuthStore.ts in your project:
import { create } from 'zustand';
// 1. Define types
interface AuthState {
user: { name: string; avatar: string } | null;
isAuthenticated: boolean;
login: (userData: { name: string; avatar: string }) => void;
logout: () => void;
}
// 2. Create Store
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
// Define state-modifying methods (Actions)
login: (userData) => set({ user: userData, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
}));
Use the Store Across Different Pages
It's that simple! Now you can read or modify this state anywhere in your app:
In Page A (Login Screen):
import { useAuthStore } from '../store/useAuthStore';
export default function LoginScreen() {
// Only destructure the login function to avoid unnecessary re-renders
const login = useAuthStore((state) => state.login);
const handleLogin = () => {
// Simulate API success
login({ name: "Ken", avatar: "https://example.com/avatar.jpg" });
};
return <Button title="Login" onPress={handleLogin} />;
}
In Page B (Homepage Header):
import { useAuthStore } from '../store/useAuthStore';
import { Text, Image } from 'react-native';
export default function Header() {
const user = useAuthStore((state) => state.user);
if (!user) return <Text>Please log in</Text>;
return (
<View>
<Image source={{ uri: user.avatar }} style={{ width: 40, height: 40 }} />
<Text>Welcome back, {user.name}!</Text>
</View>
);
}
2. API Data Fetching Best Practices: React Query (TanStack Query)
Zustand is perfect for managing "local UI state" (e.g., whether the sidebar is open, dark/light mode).
But for "data fetched from backend servers" (e.g., product lists, post lists), we strongly recommend using React Query!
In mobile apps, network conditions are highly unstable (users might lose connection in a tunnel). If we only use plain fetch or axios, we'd have to manually handle loading states, error states, refetching, and offline caching... which is a nightmare.
React Query is here to save you.
Installation:
npm install @tanstack/react-query
Implement an Infinite Scroll Product List
import { ActivityIndicator, FlatList, Text, View } from 'react-native';
import { useQuery } from '@tanstack/react-query';
// Define an API-fetching function
const fetchProducts = async () => {
const response = await fetch('https://fakestoreapi.com/products');
if (!response.ok) throw new Error('Network error');
return response.json();
};
export default function ProductList() {
// React Query's magical Hook
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['products'], // This key is used for cache management
queryFn: fetchProducts,
});
// 1. Automatically handles loading UI
if (isLoading) return <ActivityIndicator size="large" />;
// 2. Automatically handles error UI
if (isError) return <Text>Error: {error.message}</Text>;
// 3. Render the list with built-in "Pull-to-refresh" functionality
return (
<FlatList
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={{ padding: 16, borderBottomWidth: 1 }}>
<Text style={{ fontSize: 18 }}>{item.title}</Text>
<Text style={{ color: 'green' }}>${item.price}</Text>
</View>
)}
// Just these two lines enable pull-to-refresh!
refreshing={isLoading}
onRefresh={refetch}
/>
);
}
[!TIP] FlatList Performance Trick
On the web, if you need to display 1000 items, you might usedata.map()to render them all. But never do this on mobile! It will crash due to memory overload.
You must use React Native's built-in<FlatList>. It's a virtualized list that only renders items "currently visible on screen." As you scroll, it recycles memory from off-screen items for upcoming data.
With Zustand and React Query, your app's core architecture has reached industry-leading standards.
In the final chapter, we'll tackle the most daunting challenge in app developmentโbuilding and publishing to both platform stores!
Zustand Store Patterns
Store with Actions
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
const response = await api.post('/auth/login', { email, password });
set({
user: response.data.user,
token: response.data.token,
isLoading: false
});
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
logout: () => {
set({ user: null, token: null });
},
setUser: (user) => set({ user })
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage)
}
)
);
Derived State with Selectors
// Optimized selectors prevent unnecessary re-renders
const userName = useAuthStore((state) => state.user?.name);
const isAuthenticated = useAuthStore((state) => !!state.token);
const isLoading = useAuthStore((state) => state.isLoading);
// Component only re-renders when its specific selector changes
function UserGreeting() {
const name = useAuthStore((state) => state.user?.name);
return <Text>Hello, {name || 'Guest'}!</Text>;
}
function LoginButton() {
const isAuth = useAuthStore((state) => !!state.token);
const logout = useAuthStore((state) => state.logout);
if (!isAuth) return null;
return <Button title="Logout" onPress={logout} />;
}
React Query for Server State
Setup Provider
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 min before refetch
gcTime: 30 * 60 * 1000, // 30 min in cache
retry: 2,
refetchOnWindowFocus: false, // Not relevant in mobile
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Navigation />
</QueryClientProvider>
);
}
Using React Query
import { useQuery, useMutation } from '@tanstack/react-query';
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => api.get('/products').then(res => res.data),
});
}
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product: Product) => api.post('/products', product),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
function ProductList() {
const { data, isLoading, error } = useProducts();
if (isLoading) return <ActivityIndicator />;
if (error) return <Text>Error: {error.message}</Text>;
return (
<FlatList
data={data}
renderItem={({ item }) => <ProductCard product={item} />}
keyExtractor={(item) => item.id.toString()}
/>
);
}
State Management Comparison
| Solution | Type | Best For | Persistence | |----------|------|----------|-------------| | React State (useState) | Local | Simple UI state | Manual | | Context API | Shared | Low-frequency, small state | Manual | | Zustand | Global | Complex app state | Via middleware | | React Query | Server | API data, caching | Automatic | | MMKV | Persistent | Fast key-value storage | Built-in | | Redux Toolkit | Global | Large-scale apps | Via middleware |
AsyncStorage vs MMKV
| Feature | AsyncStorage | MMKV | |---------|--------------|------| | Speed | Slower (JS bridge) | 30ร faster (native) | | Sync/Async | Async only | Both | | Size Limit | ~6 MB on Android | Large (500 MB+) | | Setup | Built-in with expo | Extra package |
# MMKV installation
npx expo install react-native-mmkv
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({
id: 'my-app-storage',
encryptionKey: 'my-secret-key'
});
// Store
storage.set('user.name', 'Alice');
storage.set('user.age', 30);
storage.set('is_logged_in', true);
// Retrieve
const name = storage.getString('user.name');
const age = storage.getNumber('user.age');
const loggedIn = storage.getBoolean('is_logged_in');
Summary
State management in React Native uses Zustand for global state, React Query for server state, and MMKV for persistence. Each tool serves a specific purpose.
Key takeaways:
- Zustand: lightweight global store with selectors and persistence |
- React Query: server state with caching, refetching, and mutations |
- Zustand selectors prevent unnecessary component re-renders |
- Persist Zustand stores with AsyncStorage or MMKV middleware |
- MMKV is 30ร faster than AsyncStorage for mobile |
- React Query auto-manages loading, error, and success states |
- Use mutations for writes, queries for reads |
- Invalid queries on mutation success to refresh data |
What's Next: App Store Deployment
The next chapter covers building and publishing to app stores.