You have three primary options: React’s built-in Context API, Redux for enterprise complexity, and Zustand for lightweight elegance. Each solves the problem differently with distinct trade-offs.
How do we share state across deeply nested components without passing props through every intermediate component?
Context API: Built-In Solution
Simple Theme Context
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(‘light’);
const toggleTheme = () => {
setTheme(prev => prev === ‘light’ ? ‘dark’ : ‘light’);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error(‘useTheme must be used within ThemeProvider’);
}
return context;
}
Context API Advantages
- Zero Dependencies: Built into React, no external libraries needed
- Simple Setup: Minimal boilerplate for basic use cases
- Good for Global Data: Themes, authentication, language preferences
- Small Bundle Size: No additional code to download
Context API Limitations
// Even if they only use a small part of the context valueconst AppContext = createContext();
function Provider({ children }) {
const [user, setUser] = useState(…);
const [notifications, setNotifications] = useState(…);
return (
<AppContext.Provider value={{ user, notifications }}>
{children}
</AppContext.Provider>
);
}
// This component re-renders even if only user changes
function NotificationBell() {
const { notifications } = useContext(AppContext);
return <span>{notifications.length}</span>;
}
Redux: Enterprise Standard
Redux Store Setup
name: ‘user’,
initialState: { id: null, name: ” },
reducers: {
setUser: (state, action) => {
state.id = action.payload.id;
state.name = action.payload.name;
}
}
});
const notificationSlice = createSlice({
name: ‘notifications’,
initialState: [],
reducers: {
addNotification: (state, action) => {
state.push(action.payload);
},
removeNotification: (state, action) => {
return state.filter(n => n.id !== action.payload);
}
}
});
export const store = configureStore({
reducer: {
user: userSlice.reducer,
notifications: notificationSlice.reducer
}
});
export const { setUser } = userSlice.actions;
export const { addNotification, removeNotification } = notificationSlice.actions;
Redux with Selectors
import { setUser } from ‘./store’;function UserProfile() {
// Only re-render if user changes
const user = useSelector(state => state.user);
const dispatch = useDispatch();
const handleLogin = (userData) => {
dispatch(setUser(userData));
};
return <div>{user.name}</div>;
}
Redux Advantages
- Predictable State: Single source of truth with strict patterns
- DevTools Integration: Time-travel debugging, action history
- Middleware Support: Powerful plugin system for custom logic
- Large Ecosystem: Extensive libraries and integrations
- Performance Optimized: Granular selector-based subscriptions
Redux Disadvantages
// Need: slice, actions, reducer, selector, dispatch
// Good for large apps, overkill for small ones// Learning curve is steep for beginners
// Redux concepts (actions, reducers, middleware) take time to master
Zustand: Modern Alternative
Zustand Store Setup
user: { id: null, name: ” },
notifications: [],
setUser: (userData) => set({ user: userData }),
addNotification: (notification) =>
set((state) => ({
notifications: […state.notifications, notification]
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter(n => n.id !== id)
}))
}));
// Usage in component
function UserProfile() {
// Only re-render if user changes (automatic optimization)
const user = useAppStore((state) => state.user);
const setUser = useAppStore((state) => state.setUser);
return <div>{user.name}</div>;
}
Zustand with Persistence
import { persist } from ‘zustand/middleware’;export const useAuthStore = create(
persist(
(set) => ({
token: null,
login: (token) => set({ token }),
logout: () => set({ token: null })
}),
{
name: ‘auth-storage’, // localStorage key
storage: localStorage, // or sessionStorage
partialize: (state) => ({ token: state.token }) // Only persist token
}
)
);
Zustand Advantages
- Minimal Boilerplate: Simple, intuitive API with less code
- Small Bundle Size: ~2KB gzipped vs Redux 5KB+
- Automatic Optimization: Built-in selector memoization
- Middleware Support: Persistence, logging, etc. included
- Easy Learning Curve: Feels natural to React developers
Zustand Limitations
- Smaller Ecosystem: Fewer libraries and integrations than Redux
- Less Mature: Newer library with smaller community
- No Time-Travel Debugging: Not built into DevTools
- Less Enterprise Focus: Better for startups than large teams
Feature Comparison Table
| Feature | Context API | Redux | Zustand |
|---|---|---|---|
| Bundle Size | 0 KB | 5+ KB | ~2 KB |
| Setup Complexity | Very Simple | Complex | Simple |
| DevTools | None | Excellent | Basic |
| Middleware | Limited | Powerful | Good |
| Performance | Can be Poor | Optimized | Optimized |
| Learning Curve | Easy | Steep | Easy |
| TypeScript Support | Good | Excellent | Excellent |
Performance Considerations
Re-render Behavior
const { user, notifications } = useContext(AppContext);
// Changes to EITHER user OR notifications cause re-render// Redux: Only subscribes to selected state
const user = useSelector(state => state.user);
// Only re-renders if user changes
// Zustand: Automatic shallow comparison
const user = useAppStore((state) => state.user);
// Only re-renders if user object reference changes
Optimization Patterns
<UserContext.Provider value={user}>
<NotificationContext.Provider value={notifications}>
<App />
</NotificationContext.Provider>
</UserContext.Provider>// Redux: Use selectors and memoization
const selectUser = (state) => state.user;
const user = useSelector(selectUser);
// Zustand: Use selectors automatically
const user = useAppStore((state) => state.user);
Decision Guide: When to Use What
✅ Use Context API When:
- Application is small to medium
- State changes infrequently
- You want zero dependencies
- Sharing global config (themes, language)
- Learning React fundamentals
✅ Use Redux When:
- Large, complex applications
- Frequent state updates
- Team familiar with Redux patterns
- Need powerful DevTools and middleware
- Async operations and side effects are common
✅ Use Zustand When:
- Medium to large applications
- Want Redux power with less boilerplate
- Team prefers modern, minimal code
- Bundle size matters
- Need built-in persistence
Small app? → Context API
Large app with DevTools needs? → Redux
Large app, want simplicity? → Zustand
Migrating Between Solutions
Context API to Zustand
const UserContext = createContext();
function useUser() { return useContext(UserContext); }// AFTER: Zustand (same hook interface!)
const useUser = create((set) => ({
user: null,
setUser: (user) => set({ user })
}));
// Components don’t need to change!
const user = useUser((state) => state.user);
Incremental Migration Strategy
- Start with one slice of state
- Create new store using target library
- Gradually move components over
- Test thoroughly between migrations
- Keep old state management until fully migrated
Patterns and Best Practices
Universal Pattern: Custom Hooks
export const useUser = () => useAppStore((state) => state.user);
export const useSetUser = () => useAppStore((state) => state.setUser);
export const useNotifications = () => useAppStore((state) => state.notifications);// Components use consistent interface regardless of store
function Profile() {
const user = useUser();
const setUser = useSetUser();
// Implementation hidden from component
}
Selectors for Complex Queries
users: [],
filter: ‘all’,// Selectors for derived state
getFilteredUsers: (state) => {
if (state.filter === ‘active’) {
return state.users.filter(u => u.active);
}
return state.users;
},
getUserCount: (state) => state.users.length
}));
// In component
const filtered = useAppStore((state) => state.getFilteredUsers(state));
const count = useAppStore((state) => state.getUserCount(state));
Testing State Management
const { result } = renderHook(() => useAppStore());
act(() => {
result.current.setUser({ id: 1, name: ‘John’ });
});
expect(result.current.user.name).toBe(‘John’);
});