Read Time: ~14 minutes | Building on React fundamentals to master state management at scale
Prerequisites: Familiarity with React basics, useState, useEffect (Part 1)
π Series Navigation
β Part 1: Complete Guide from Zero to Production
Part 2: Advanced Hooks & State Management β YOU ARE HERE
β Part 3: Performance Optimization (coming next)
π What You'll Learn
By the end of this guide, you'll understand:
- β Creating powerful custom hooks
- β When and how to use useReducer
- β Managing state globally with Context API
- β Redux fundamentals and when to use it
- β Modern alternatives: Zustand and Jotai
- β Choosing the right pattern for your project
- β Real-world shopping cart implementation
π£ Custom Hooks: Reusing Logic Across Components
Custom hooks are regular JavaScript functions that let you extract component logic into reusable functions. They're one of the most powerful React patterns.
Rule #1: Custom Hooks Must Start with "use"
// β
Correct - starts with "use"
function useFormInput(initialValue) {
const [value, setValue] = useState(initialValue);
return {
value,
bind: {
value,
onChange: e => setValue(e.target.value)
},
reset: () => setValue(initialValue)
};
}
// β Wrong - doesn't start with "use"
function formInput(initialValue) { ... }
Example #1: useFormInput Hook
import { useState } from 'react';
function useFormInput(initialValue = '') {
const [value, setValue] = useState(initialValue);
return {
value,
setValue,
bind: {
value,
onChange: e => setValue(e.target.value)
},
reset: () => setValue(initialValue)
};
}
// Using the custom hook
function LoginForm() {
const email = useFormInput('');
const password = useFormInput('');
const handleSubmit = (e) => {
e.preventDefault();
console.log(email.value, password.value);
email.reset();
password.reset();
};
return (
<form onSubmit={handleSubmit}>
<input {...email.bind} placeholder="Email" type="email" />
<input {...password.bind} placeholder="Password" type="password" />
<button type="submit">Login</button>
</form>
);
}
Why it's powerful: Logic is extracted once, reused everywhere. Need form input validation? Add it once to the hook.
Example #2: useFetch Hook - Data Loading Pattern
This is one of the most practical custom hooks for real projects:
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true; // Prevent memory leaks
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, options);
if (!response.ok) throw new Error(`Error: ${response.status}`);
const json = await response.json();
if (isMounted) setData(json);
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
return () => { isMounted = false; }; // Cleanup
}, [url, options]);
return { data, loading, error };
}
// Using it
function ProductList() {
const { data: products, loading, error } = useFetch('/api/products');
if (loading) return <p>Loading products...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
Key insight: The component doesn't need to know about fetch logicβit just uses the hook.
Example #3: useLocalStorage Hook - Persistence Pattern
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Using it - Dark mode preference persists across page reloads
function App() {
const [isDarkMode, setIsDarkMode] = useLocalStorage('darkMode', false);
return (
<div className={isDarkMode ? 'dark' : 'light'}>
<button onClick={() => setIsDarkMode(!isDarkMode)}>
Toggle Dark Mode
</button>
</div>
);
}
π useReducer: Complex State Logic
When you have multiple state variables that depend on each other, useReducer is cleaner than useState.
useReducer vs useState
| Scenario | Best Choice |
|---|---|
| Single simple value (count, toggle) | useState |
| Multiple related values | useReducer |
| State depends on previous state | useReducer |
| Complex state transitions | useReducer |
| Need to pass dispatch to children | useReducer |
Complete useReducer Example: Todo App
import { useReducer } from 'react';
// Reducer function - pure, testable
function todoReducer(state, action) {
switch(action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload,
completed: false
}]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
default:
return state;
}
}
function TodoApp() {
const initialState = {
todos: [],
filter: 'all' // all, active, completed
};
const [state, dispatch] = useReducer(todoReducer, initialState);
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
return (
<div>
<input
onKeyPress={(e) => {
if (e.key === 'Enter') {
dispatch({ type: 'ADD_TODO', payload: e.target.value });
e.target.value = '';
}
}}
placeholder="Add a todo..."
/>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch({
type: 'TOGGLE_TODO',
payload: todo.id
})}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button onClick={() => dispatch({
type: 'DELETE_TODO',
payload: todo.id
})}>
Delete
</button>
</li>
))}
</ul>
<div>
{['all', 'active', 'completed'].map(f => (
<button
key={f}
onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}
style={{
fontWeight: state.filter === f ? 'bold' : 'normal'
}}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
</div>
);
}
Why useReducer wins here:
- All state changes go through
dispatchβeasy to debug and test - Reducer is a pure functionβpredictable and testable
- Complex state logic stays organized in one place
π Context API: Global State Without Libraries
Context API lets you share state across components without prop drilling.
Problem: Prop Drilling
// β Passing props through 5 levels is messy
<App theme={theme} />
<Header theme={theme} />
<Nav theme={theme} />
<Button theme={theme} />
Solution: Context API
import { createContext, useContext, useState } from 'react';
// Step 1: Create context
const ThemeContext = createContext();
// Step 2: Create provider component
export function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(false);
const value = {
isDark,
toggleTheme: () => setIsDark(prev => !prev),
colors: isDark
? { bg: '#1a1a1a', text: '#ffffff' }
: { bg: '#ffffff', text: '#000000' }
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// Step 3: Create custom hook to use context
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Step 4: Wrap your app
function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
<Footer />
</ThemeProvider>
);
}
// Step 5: Use anywhere
function Button() {
const { isDark, toggleTheme, colors } = useTheme();
return (
<button
onClick={toggleTheme}
style={{ background: colors.bg, color: colors.text }}
>
{isDark ? 'βοΈ Light' : 'π Dark'}
</button>
);
}
When to use Context API:
- β Theme settings (light/dark mode)
- β User authentication state
- β Language/locale preferences
- β Frequently changing data (performance issues)
π¦ Redux: Enterprise-Grade State Management
Redux is a predictable state container. It's overkill for small apps but essential for large ones.
Redux Flow
Action β Reducer β Store β Components Update
Complete Redux Example: Shopping Cart
import { createStore } from 'redux';
// Step 1: Define initial state
const initialState = {
items: [],
total: 0,
loading: false
};
// Step 2: Create reducer
function cartReducer(state = initialState, action) {
switch(action.type) {
case 'ADD_ITEM':
const existingItem = state.items.find(
item => item.id === action.payload.id
);
if (existingItem) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
total: state.total + action.payload.price
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
total: state.total + action.payload.price
};
case 'REMOVE_ITEM':
const removedItem = state.items.find(
item => item.id === action.payload
);
return {
...state,
items: state.items.filter(item => item.id !== action.payload),
total: state.total - (removedItem.price * removedItem.quantity)
};
case 'CLEAR_CART':
return initialState;
default:
return state;
}
}
// Step 3: Create store
const store = createStore(cartReducer);
// Step 4: Dispatch actions
store.dispatch({
type: 'ADD_ITEM',
payload: { id: 1, name: 'Laptop', price: 1200 }
});
console.log(store.getState());
// { items: [{id: 1, name: 'Laptop', price: 1200, quantity: 1}], total: 1200 }
Redux Pros & Cons:
- β Predictable state changes
- β Time-travel debugging (Redux DevTools)
- β Great for large, complex apps
- β Boilerplate code (actions, reducers, selectors)
- β Steep learning curve
β‘ Zustand: Lightweight Alternative to Redux
Zustand is simpler than Redux with minimal boilerplate.
import { create } from 'zustand';
// Create store in one place
const useCartStore = create((set) => ({
items: [],
total: 0,
// Actions
addItem: (item) => set((state) => ({
items: [...state.items, item],
total: state.total + item.price
})),
removeItem: (id) => set((state) => {
const removedItem = state.items.find(item => item.id === id);
return {
items: state.items.filter(item => item.id !== id),
total: state.total - removedItem.price
};
}),
clearCart: () => set({
items: [],
total: 0
})
}));
// Use anywhere - no Provider needed!
function ShoppingCart() {
const { items, total, removeItem } = useCartStore();
return (
<div>
<h2>Cart: ${total}</h2>
{items.map(item => (
<div key={item.id}>
{item.name} - ${item.price}
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
</div>
);
}
Why Zustand is Awesome:
- β No Provider wrapper needed
- β Minimal boilerplate
- β Great performance
- β TypeScript friendly
- β Smaller bundle size
π― Jotai: Atom-Based State Management
Jotai takes a different approachβprimitive atoms instead of a single store.
import { atom, useAtom } from 'jotai';
// Create atoms (primitive pieces of state)
const cartItemsAtom = atom([]);
const cartTotalAtom = atom(0);
// Derived atom (computed from other atoms)
const cartCountAtom = atom(
(get) => get(cartItemsAtom).length
);
// Use atoms in components
function ShoppingCart() {
const [items, setItems] = useAtom(cartItemsAtom);
const [total, setTotal] = useAtom(cartTotalAtom);
const [count] = useAtom(cartCountAtom);
const addItem = (item) => {
setItems([...items, item]);
setTotal(total + item.price);
};
return (
<div>
<h2>Cart ({count} items): ${total}</h2>
<button onClick={() => addItem({ name: 'Laptop', price: 1200 })}>
Add Laptop
</button>
</div>
);
}
Jotai Benefits:
- β Fine-grained reactivity
- β Only components using changed atoms re-render
- β Great for complex interdependent state
- β No Provider overhead
π Real-World Example: Shopping Cart with Multiple Patterns
Let's build a shopping cart using Context API + useReducer (the practical combination):
import { createContext, useContext, useReducer } from 'react';
// 1. Create context
const CartContext = createContext();
// 2. Reducer
function cartReducer(state, action) {
switch(action.type) {
case 'ADD_TO_CART': {
const existingItem = state.items.find(
item => item.id === action.payload.id
);
if (existingItem) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
)
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }]
};
}
case 'REMOVE_FROM_CART':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
).filter(item => item.quantity > 0)
};
case 'CLEAR_CART':
return { ...state, items: [] };
default:
return state;
}
}
// 3. Provider component
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
const total = state.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
const value = {
items: state.items,
total,
addToCart: (product) => dispatch({ type: 'ADD_TO_CART', payload: product }),
removeFromCart: (id) => dispatch({ type: 'REMOVE_FROM_CART', payload: id }),
updateQuantity: (id, quantity) => dispatch({
type: 'UPDATE_QUANTITY',
payload: { id, quantity }
}),
clearCart: () => dispatch({ type: 'CLEAR_CART' })
};
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
}
// 4. Custom hook
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
// 5. Usage
function ProductCard({ product }) {
const { addToCart } = useCart();
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addToCart(product)}>
Add to Cart
</button>
</div>
);
}
function Cart() {
const { items, total, removeFromCart, updateQuantity } = useCart();
return (
<div>
<h2>Shopping Cart</h2>
{items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<>
{items.map(item => (
<div key={item.id}>
<h4>{item.name}</h4>
<input
type="number"
value={item.quantity}
onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
/>
<p>${item.price * item.quantity}</p>
<button onClick={() => removeFromCart(item.id)}>Remove</button>
</div>
))}
<h3>Total: ${total.toFixed(2)}</h3>
</>
)}
</div>
);
}
// 6. App structure
function App() {
return (
<CartProvider>
<ProductCard product={{ id: 1, name: 'Laptop', price: 1200 }} />
<Cart />
</CartProvider>
);
}
π― Choosing Your State Management Tool
Decision Tree
Is your app small (< 5 components)?
ββ Yes β Use useState directly
ββ No β Continue
Do you need to share state between many components?
ββ Yes β Continue
ββ No β Use useState + prop drilling
Is the state changing frequently (> 10 times/sec)?
ββ Yes β Use Jotai or Zustand
ββ No β Continue
Is your team familiar with Redux?
ββ Yes β Consider Redux
ββ No β Consider Zustand
Do you need middleware (logging, analytics)?
ββ Yes β Use Redux
ββ No β Use Zustand or Context API
Quick Comparison
| Tool | Best For | Learning Curve | Bundle Size |
|---|---|---|---|
| useState | Simple state | Easiest | 0 KB |
| useReducer | Complex state | Easy | 0 KB |
| Context API | Theme, auth | Easy | 0 KB |
| Redux | Large apps | Hard | 10 KB |
| Zustand | Medium apps | Easy | 2 KB |
| Jotai | Granular updates | Medium | 5 KB |
π Performance Considerations
Context API Performance Pitfall
// β BAD - Entire app re-renders on state change
function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(false);
return (
<ThemeContext.Provider value={{ isDark, setIsDark }}>
{children}
</ThemeContext.Provider>
);
}
// β
GOOD - Memoize context value
function ThemeProvider({ children }) {
const [isDark, setIsDark] = useState(false);
const value = useMemo(() => ({ isDark, setIsDark }), [isDark]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Key insight: Zustand and Jotai automatically optimize this. Redux requires careful selector use.
π Advanced Pattern: Combining Approaches
Real projects often use multiple state management tools:
// Global app state β Redux/Zustand
// Theme & Auth β Context API
// UI state (modals, dropdowns) β useState
// Form data β Custom useForm hook
This is normal and healthyβdon't over-engineer.
π‘ Final Thoughts: State Management Maturity
Beginner Phase
- Use useState everywhere
- Props for simple sharing
- useReducer when state gets complex
Intermediate Phase
- Context API for theme, auth, user
- useReducer for complex components
- Custom hooks for reusable logic
Advanced Phase
- Zustand for app state
- Context for cross-cutting concerns
- Jotai for highly granular updates
- Redux only if you have 50+ components
π Key Takeaways
- Custom hooks are the foundationβmaster these first
- useReducer scales better than useState for complex state
- Context API is perfect for theme/auth, not data caches
- Zustand is the modern, simple alternative to Redux
- Jotai excels at fine-grained reactivity
- Choose based on app size, not hype
π Quick Resources
- Redux Docs: redux.js.org
- Zustand: github.com/pmndrs/zustand
- Jotai: jotai.org
- React Query (for server state): tanstack.com/query
- Context API Docs: react.dev/reference/react/useContext
π¬ What's Your State Management Journey?
Have you struggled with prop drilling? Built a custom hook? Migrated from Redux? Share your experience in the commentsβI'd love to hear what state management patterns your team uses!
π Series Roadmap
β Part 1: Complete Guide from Zero to Production
Part 2: Advanced Hooks & State Management Patterns β YOU ARE HERE
β [Part 3: Performance Optimization & Advanced Patterns] (coming-soon in 2 weeks)
Next in Part 3:
- useMemo & useCallback deep dive
- Code splitting and lazy loading
- Suspense and React.lazy()
- Performance profiling with DevTools
- Real optimization case studies
Happy state managing! π
Top comments (0)