Chapter 3: State Management - Mastering React Application State
Welcome to the comprehensive guide to React state management! In this chapter, we'll explore everything you need to know about managing state in React applications, from simple local state to complex global state management patterns.
Learning Objectives
By the end of this chapter, you will understand:
- What state management means in React and why it's crucial for modern applications
- How to choose the right state management approach for different scenarios
- Why different state management patterns exist and when to use each one
- What local state is and how to manage it effectively with useState and useReducer
- How to implement global state using Context API and external libraries
- Why performance matters in state management and how to optimize it
- What best practices are for building maintainable state management systems
What is State Management? The Foundation of Interactive React Applications
What is State in React?
State in React represents the data that can change over time and affects what the user sees. It's what makes your React components interactive and dynamic.
State is what makes React applications truly interactive. Without state, your components would be static and unable to respond to user interactions or external changes.
What Types of State Exist in React Applications?
React applications typically have several types of state:
- Local State: State that belongs to a single component
- Shared State: State that needs to be accessed by multiple components
- Global State: State that needs to be accessed throughout the entire application
- Server State: Data fetched from external APIs
- Form State: Data entered by users in forms
- UI State: State that controls the user interface (modals, loading states, etc.)
What Problems Does State Management Solve?
Without proper state management, React applications face several challenges:
- Data Synchronization: Keeping multiple components in sync with the same data
- Performance Issues: Unnecessary re-renders and poor optimization
- Code Complexity: Difficult to understand and maintain state logic
- Testing Difficulties: Hard to test components with complex state dependencies
- Scalability Problems: Applications become harder to scale as they grow
How to Manage Local State? The useState Hook Deep Dive
What is Local State?
Local state is state that belongs to a single component and doesn't need to be shared with other components. It's the simplest form of state management in React.
Local state is what makes individual components interactive and responsive to user actions.
How to Use useState for Basic State Management?
The useState
hook is the most fundamental way to manage local state in functional components:
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
<button onClick={() => setCount(0)}>
Reset
</button>
</div>
);
}
How to Handle Complex Local State?
For more complex state, you can use multiple useState hooks or manage related state together:
function UserProfile() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0,
isActive: false
});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleInputChange = (field, value) => {
setUser(prevUser => ({
...prevUser,
[field]: value
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prevErrors => ({
...prevErrors,
[field]: ''
}));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
// Validate form
const newErrors = {};
if (!user.name) newErrors.name = 'Name is required';
if (!user.email) newErrors.email = 'Email is required';
if (user.age < 0) newErrors.age = 'Age must be positive';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Submit user data
await saveUser(user);
console.log('User saved successfully');
} catch (error) {
console.error('Error saving user:', error);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input
type="text"
value={user.name}
onChange={(e) => handleInputChange('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label>Email:</label>
<input
type="email"
value={user.email}
onChange={(e) => handleInputChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label>Age:</label>
<input
type="number"
value={user.age}
onChange={(e) => handleInputChange('age', parseInt(e.target.value))}
/>
{errors.age && <span className="error">{errors.age}</span>}
</div>
<div>
<label>
<input
type="checkbox"
checked={user.isActive}
onChange={(e) => handleInputChange('isActive', e.target.checked)}
/>
Active User
</label>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save User'}
</button>
</form>
);
}
How to Use Functional Updates with useState?
When updating state based on previous state, use functional updates to avoid stale closures:
function Counter() {
const [count, setCount] = useState(0);
// ❌ Wrong - can lead to stale closures
const increment = () => {
setCount(count + 1);
};
// ✅ Correct - uses functional update
const incrementCorrect = () => {
setCount(prevCount => prevCount + 1);
};
// ✅ Correct - multiple updates in sequence
const incrementByThree = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={incrementCorrect}>Increment</button>
<button onClick={incrementByThree}>Increment by 3</button>
</div>
);
}
Why Use Local State? The Benefits and Limitations
Why is Local State Important?
Local state provides several benefits:
- Simplicity: Easy to understand and implement
- Performance: Only affects the component that owns the state
- Encapsulation: State is contained within the component
- Testing: Easy to test components with local state
- Reusability: Components with local state are more reusable
Why Does Local State Have Limitations?
Local state has limitations that become apparent as applications grow:
- No Sharing: Can't be accessed by other components
- Props Drilling: Must pass state down through multiple component layers
- Complexity: Managing related state across multiple components becomes difficult
- Synchronization: Keeping multiple components in sync is challenging
When Should You Use Local State?
Use local state when:
- State only affects a single component
- State doesn't need to be shared with other components
- State is simple and doesn't require complex logic
- You're building small, focused components
How to Manage Complex State? The useReducer Hook
What is useReducer?
useReducer
is a React hook that provides a more powerful way to manage complex state. It's similar to Redux's reducer pattern and is ideal for state that involves multiple sub-values or when the next state depends on the previous one.
useReducer is what you use when useState becomes too complex or when you need more control over state updates.
How to Use useReducer for Complex State?
import React, { useReducer } from 'react';
// Define the reducer function
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
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
// Initial state
const initialState = {
todos: [],
filter: 'all' // 'all', 'active', 'completed'
};
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const addTodo = (text) => {
dispatch({ type: 'ADD_TODO', payload: text });
};
const toggleTodo = (id) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
const deleteTodo = (id) => {
dispatch({ type: 'DELETE_TODO', payload: id });
};
const setFilter = (filter) => {
dispatch({ type: 'SET_FILTER', payload: filter });
};
const clearCompleted = () => {
dispatch({ type: 'CLEAR_COMPLETED' });
};
// Filter todos based on current filter
const filteredTodos = state.todos.filter(todo => {
switch (state.filter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
return (
<div>
<h1>Todo App</h1>
<form onSubmit={(e) => {
e.preventDefault();
const input = e.target.elements.todo;
if (input.value.trim()) {
addTodo(input.value.trim());
input.value = '';
}
}}>
<input
name="todo"
type="text"
placeholder="Add a new todo..."
/>
<button type="submit">Add Todo</button>
</form>
<div>
<button
onClick={() => setFilter('all')}
className={state.filter === 'all' ? 'active' : ''}
>
All
</button>
<button
onClick={() => setFilter('active')}
className={state.filter === 'active' ? 'active' : ''}
>
Active
</button>
<button
onClick={() => setFilter('completed')}
className={state.filter === 'completed' ? 'active' : ''}
>
Completed
</button>
<button onClick={clearCompleted}>
Clear Completed
</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>
Delete
</button>
</li>
))}
</ul>
<p>
{state.todos.filter(todo => !todo.completed).length} items left
</p>
</div>
);
}
How to Use useReducer with Lazy Initialization?
For expensive initial state calculations, you can use lazy initialization:
function init(initialCount) {
return {
count: initialCount,
history: [initialCount],
operations: []
};
}
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return {
count: state.count + 1,
history: [...state.history, state.count + 1],
operations: [...state.operations, 'increment']
};
case 'decrement':
return {
count: state.count - 1,
history: [...state.history, state.count - 1],
operations: [...state.operations, 'decrement']
};
case 'reset':
return init(action.payload);
default:
throw new Error();
}
}
function Counter({ initialCount = 0 }) {
const [state, dispatch] = useReducer(counterReducer, initialCount, init);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset', payload: initialCount })}>
Reset
</button>
<div>
<h3>History:</h3>
<p>{state.history.join(', ')}</p>
</div>
<div>
<h3>Operations:</h3>
<p>{state.operations.join(', ')}</p>
</div>
</div>
);
}
Why Use useReducer Instead of useState? The Benefits Explained
Why Choose useReducer for Complex State?
useReducer is better than useState when:
- Complex State Logic: State involves multiple sub-values
- State Dependencies: Next state depends on previous state
- Predictable Updates: State updates follow a clear pattern
- Testing: Easier to test complex state logic
- Debugging: Better debugging experience with action-based updates
Why is useReducer More Predictable?
useReducer provides predictable state updates because:
- Action-Based: All state changes go through defined actions
- Pure Functions: Reducers are pure functions that are easy to test
- Time Travel: You can replay actions for debugging
- Centralized Logic: All state logic is in one place
When Should You Use useReducer?
Use useReducer when:
- State logic is complex and involves multiple sub-values
- State updates depend on previous state
- You need to implement undo/redo functionality
- State logic is shared between multiple components
- You want to make state updates more predictable and testable
How to Implement Global State? Context API and Beyond
What is Global State?
Global state is state that needs to be accessed by multiple components throughout your application. It's different from local state because it's shared across component boundaries.
Global state is what enables data sharing between components that don't have a direct parent-child relationship.
How to Use Context API for Global State?
The Context API is React's built-in solution for sharing state across components:
import React, { createContext, useContext, useReducer } from 'react';
// Create context
const AppContext = createContext();
// Define reducer for global state
function appReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return {
...state,
user: action.payload,
isAuthenticated: !!action.payload
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload
};
case 'SET_ERROR':
return {
...state,
error: action.payload
};
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload]
};
case 'REMOVE_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.payload)
};
default:
return state;
}
}
// Initial state
const initialState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
notifications: []
};
// Provider component
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
const actions = {
setUser: (user) => dispatch({ type: 'SET_USER', payload: user }),
setLoading: (loading) => dispatch({ type: 'SET_LOADING', payload: loading }),
setError: (error) => dispatch({ type: 'SET_ERROR', payload: error }),
addNotification: (notification) => dispatch({
type: 'ADD_NOTIFICATION',
payload: { ...notification, id: Date.now() }
}),
removeNotification: (id) => dispatch({ type: 'REMOVE_NOTIFICATION', payload: id })
};
const value = {
...state,
...actions
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// Custom hook for consuming context
function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}
// Usage in components
function Header() {
const { user, isAuthenticated, setUser } = useApp();
const handleLogout = () => {
setUser(null);
};
return (
<header>
<h1>My App</h1>
{isAuthenticated ? (
<div>
<span>Welcome, {user.name}!</span>
<button onClick={handleLogout}>Logout</button>
</div>
) : (
<button onClick={() => setUser({ name: 'John Doe', email: '[email protected]' })}>
Login
</button>
)}
</header>
);
}
function NotificationCenter() {
const { notifications, removeNotification } = useApp();
return (
<div className="notification-center">
{notifications.map(notification => (
<div key={notification.id} className="notification">
<span>{notification.message}</span>
<button onClick={() => removeNotification(notification.id)}>×</button>
</div>
))}
</div>
);
}
function App() {
return (
<AppProvider>
<Header />
<NotificationCenter />
{/* Other components */}
</AppProvider>
);
}
How to Create Custom Hooks for State Logic?
Custom hooks allow you to extract state logic and reuse it across components:
// Custom hook for form state
function useForm(initialValues = {}) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (name, value) => {
setValues(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const handleSubmit = async (onSubmit) => {
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setIsSubmitting(false);
};
return {
values,
errors,
isSubmitting,
handleChange,
handleSubmit,
reset
};
}
// Custom hook for API data fetching
function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [url, options]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage
function UserForm() {
const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm({
name: '',
email: ''
});
const onSubmit = async (formData) => {
// Submit form data
console.log('Submitting:', formData);
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(onSubmit);
}}>
<input
name="name"
value={values.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Name"
/>
{errors.name && <span className="error">{errors.name}</span>}
<input
name="email"
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="Email"
type="email"
/>
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
function UserList() {
const { data: users, loading, error, refetch } = useApi('/api/users');
if (loading) return <div>Loading users...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Why Use Different State Management Approaches? The Benefits Explained
Why Choose Context API Over Props Drilling?
Props drilling problems:
// ❌ Props drilling - data passed through multiple levels
function App() {
const [user, setUser] = useState(null);
return <Header user={user} setUser={setUser} />;
}
function Header({ user, setUser }) {
return <Navigation user={user} setUser={setUser} />;
}
function Navigation({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}
function UserMenu({ user, setUser }) {
return <div>Welcome, {user?.name}</div>;
}
Context API solution:
// ✅ Context API - direct access to state
function App() {
return (
<UserProvider>
<Header />
</UserProvider>
);
}
function Header() {
return <Navigation />;
}
function Navigation() {
return <UserMenu />;
}
function UserMenu() {
const { user } = useUser();
return <div>Welcome, {user?.name}</div>;
}
Why Use Custom Hooks?
Custom hooks provide:
- Code Reuse: Share state logic across multiple components
- Separation of Concerns: Keep business logic separate from UI
- Testing: Easy to test state logic in isolation
- Composability: Combine multiple hooks for complex functionality
- Readability: Make components cleaner and more focused
Why Consider External State Management Libraries?
For complex applications, external libraries like Redux or Zustand offer:
- Predictable State Updates: Clear rules for how state changes
- Time Travel Debugging: Ability to replay state changes
- Middleware Support: Add functionality like logging, persistence
- DevTools Integration: Better debugging experience
- Performance Optimization: Built-in optimizations for large apps
State Management Best Practices
What Are State Management Best Practices?
- Keep State Local When Possible: Don't lift state up unless necessary
- Use the Right Tool: Choose the appropriate state management solution
- Normalize State: Keep state structure flat and normalized
- Avoid Mutations: Always create new state objects instead of mutating
- Use Memoization: Optimize performance with useMemo and useCallback
- Handle Loading and Error States: Always consider async operations
How to Structure State Effectively?
// ✅ Good state structure
const initialState = {
// User data
user: {
id: null,
name: '',
email: '',
preferences: {}
},
// UI state
ui: {
isLoading: false,
error: null,
currentPage: 'home',
modals: {
login: false,
settings: false
}
},
// Data collections
entities: {
posts: {},
comments: {},
users: {}
},
// Lists and pagination
lists: {
posts: {
ids: [],
loading: false,
error: null,
pagination: {
page: 1,
hasMore: true
}
}
}
};
// ✅ Good state updates
function userReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return {
...state,
user: {
...state.user,
...action.payload
}
};
case 'SET_LOADING':
return {
...state,
ui: {
...state.ui,
isLoading: action.payload
}
};
case 'ADD_POST':
return {
...state,
entities: {
...state.entities,
posts: {
...state.entities.posts,
[action.payload.id]: action.payload
}
},
lists: {
...state.lists,
posts: {
...state.lists.posts,
ids: [...state.lists.posts.ids, action.payload.id]
}
}
};
default:
return state;
}
}
How to Optimize State Performance?
// Memoize expensive calculations
function ExpensiveComponent({ data }) {
const processedData = useMemo(() => {
return data.map(item => ({
...item,
processed: expensiveCalculation(item)
}));
}, [data]);
const handleUpdate = useCallback((id, updates) => {
// Update logic
}, []);
return (
<div>
{processedData.map(item => (
<Item
key={item.id}
data={item}
onUpdate={handleUpdate}
/>
))}
</div>
);
}
// Split context to avoid unnecessary re-renders
const UserContext = createContext();
const UIContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [ui, setUI] = useState({ isLoading: false });
return (
<UserContext.Provider value={{ user, setUser }}>
<UIContext.Provider value={{ ui, setUI }}>
{children}
</UIContext.Provider>
</UserContext.Provider>
);
}
Summary: Mastering React State Management
What Have We Learned?
In this chapter, we've explored the complete spectrum of React state management:
- Local State: useState and useReducer for component-level state
- Global State: Context API for sharing state across components
- Custom Hooks: Reusable state logic and side effects
- Best Practices: Performance optimization and state structure
- Real-world Patterns: Form handling, API integration, and error management
How to Choose the Right State Management Approach?
- Start Simple: Use local state (useState) for component-specific data
- Lift State Up: Move shared state to the nearest common ancestor
- Use Context: For state that needs to be accessed by many components
- Consider External Libraries: For complex applications with heavy state logic
- Custom Hooks: Extract and reuse state logic across components
Why State Management Matters for Your React Journey?
Understanding state management is crucial because:
- User Experience: Proper state management leads to better user experiences
- Performance: Efficient state updates improve application performance
- Maintainability: Well-structured state makes code easier to maintain
- Scalability: Good state management patterns scale with your application
- Developer Experience: Clear state patterns make development more enjoyable
Next Steps
Now that you understand state management, you're ready to explore:
- Event Handling: How to handle user interactions effectively
- Advanced Hooks: How to use React hooks for complex scenarios
- Performance Optimization: How to build fast, responsive applications
- Testing: How to test components with complex state
Remember: The best state management solution is the one that fits your application's needs and your team's expertise. Start simple and evolve as your requirements grow.
State with Objects
function UserForm() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0,
preferences: {
theme: 'light',
notifications: true
}
});
const updateUser = (field, value) => {
setUser(prevUser => ({
...prevUser,
[field]: value
}));
};
const updatePreferences = (prefField, value) => {
setUser(prevUser => ({
...prevUser,
preferences: {
...prevUser.preferences,
[prefField]: value
}
}));
};
return (
<div>
<input
value={user.name}
onChange={(e) => updateUser('name', e.target.value)}
placeholder="Name"
/>
<input
value={user.email}
onChange={(e) => updateUser('email', e.target.value)}
placeholder="Email"
type="email"
/>
<input
value={user.age}
onChange={(e) => updateUser('age', Number(e.target.value))}
placeholder="Age"
type="number"
/>
<select
value={user.preferences.theme}
onChange={(e) => updatePreferences('theme', e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label>
<input
type="checkbox"
checked={user.preferences.notifications}
onChange={(e) => updatePreferences('notifications', e.target.checked)}
/>
Enable notifications
</label>
</div>
);
}
Complex State with useReducer
useReducer
is useful for managing complex state logic with multiple sub-values or when the next state depends on the previous one.
Basic useReducer
import React, { useReducer } from 'react';
// Reducer function
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>
Increment
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
Decrement
</button>
<button onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'set', payload: 10 })}>
Set to 10
</button>
</div>
);
}
Complex State Management
// Todo reducer
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
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all'
});
const addTodo = (text) => {
dispatch({ type: 'ADD_TODO', payload: text });
};
const toggleTodo = (id) => {
dispatch({ type: 'TOGGLE_TODO', payload: id });
};
const deleteTodo = (id) => {
dispatch({ type: 'DELETE_TODO', payload: id });
};
const setFilter = (filter) => {
dispatch({ type: 'SET_FILTER', payload: filter });
};
const clearCompleted = () => {
dispatch({ type: 'CLEAR_COMPLETED' });
};
const filteredTodos = state.todos.filter(todo => {
switch (state.filter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoFilter filter={state.filter} onFilterChange={setFilter} />
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<button onClick={clearCompleted}>Clear Completed</button>
</div>
);
}
Global State with Context API
The Context API allows you to share state across multiple components without prop drilling.
Creating Context
import React, { createContext, useContext, useReducer } from 'react';
// Create context
const AppContext = createContext();
// Initial state
const initialState = {
user: null,
theme: 'light',
notifications: [],
isLoading: false
};
// Reducer
function appReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload]
};
case 'REMOVE_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.payload)
};
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state;
}
}
// Provider component
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
const actions = {
setUser: (user) => dispatch({ type: 'SET_USER', payload: user }),
setTheme: (theme) => dispatch({ type: 'SET_THEME', payload: theme }),
addNotification: (notification) => dispatch({
type: 'ADD_NOTIFICATION',
payload: { ...notification, id: Date.now() }
}),
removeNotification: (id) => dispatch({ type: 'REMOVE_NOTIFICATION', payload: id }),
setLoading: (loading) => dispatch({ type: 'SET_LOADING', payload: loading })
};
return (
<AppContext.Provider value={{ state, actions }}>
{children}
</AppContext.Provider>
);
}
// Custom hook
function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within an AppProvider');
}
return context;
}
export { AppProvider, useApp };
Using Context
// App component
function App() {
return (
<AppProvider>
<Header />
<Main />
<Footer />
</AppProvider>
);
}
// Header component
function Header() {
const { state, actions } = useApp();
const toggleTheme = () => {
actions.setTheme(state.theme === 'light' ? 'dark' : 'light');
};
return (
<header className={`header ${state.theme}`}>
<h1>My App</h1>
<button onClick={toggleTheme}>
Switch to {state.theme === 'light' ? 'dark' : 'light'} theme
</button>
{state.user && <span>Welcome, {state.user.name}!</span>}
</header>
);
}
// Main component
function Main() {
const { state, actions } = useApp();
const handleLogin = () => {
actions.setUser({ name: 'John Doe', email: '[email protected]' });
actions.addNotification({
type: 'success',
message: 'Successfully logged in!'
});
};
return (
<main>
{state.user ? (
<Dashboard />
) : (
<button onClick={handleLogin}>Login</button>
)}
<NotificationList />
</main>
);
}
// Notification component
function NotificationList() {
const { state, actions } = useApp();
return (
<div className="notifications">
{state.notifications.map(notification => (
<div
key={notification.id}
className={`notification ${notification.type}`}
>
{notification.message}
<button onClick={() => actions.removeNotification(notification.id)}>
×
</button>
</div>
))}
</div>
);
}
Custom Hooks for State Logic
Custom hooks allow you to extract state logic into reusable functions.
Data Fetching Hook
import { useState, useEffect } from 'react';
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (!cancelled) {
setData(result);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
</div>
);
}
Form State Hook
function useForm(initialValues, validationRules = {}) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const setValue = (name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const setFieldTouched = (name) => {
setTouched(prev => ({ ...prev, [name]: true }));
};
const validate = () => {
const newErrors = {};
Object.keys(validationRules).forEach(field => {
const rule = validationRules[field];
const value = values[field];
if (rule.required && (!value || value.trim() === '')) {
newErrors[field] = rule.required;
} else if (rule.pattern && !rule.pattern.test(value)) {
newErrors[field] = rule.pattern.message;
} else if (rule.minLength && value.length < rule.minLength) {
newErrors[field] = rule.minLength.message;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (onSubmit) => (e) => {
e.preventDefault();
if (validate()) {
onSubmit(values);
}
};
const reset = () => {
setValues(initialValues);
setErrors({});
setTouched({});
};
return {
values,
errors,
touched,
setValue,
setFieldTouched,
validate,
handleSubmit,
reset
};
}
// Usage
function ContactForm() {
const validationRules = {
name: {
required: 'Name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' }
},
email: {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email'
}
}
};
const {
values,
errors,
touched,
setValue,
setFieldTouched,
handleSubmit
} = useForm({ name: '', email: '' }, validationRules);
const onSubmit = (formValues) => {
console.log('Form submitted:', formValues);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
type="text"
name="name"
value={values.name}
onChange={(e) => setValue('name', e.target.value)}
onBlur={() => setFieldTouched('name')}
placeholder="Name"
/>
{touched.name && errors.name && (
<span className="error">{errors.name}</span>
)}
</div>
<div>
<input
type="email"
name="email"
value={values.email}
onChange={(e) => setValue('email', e.target.value)}
onBlur={() => setFieldTouched('email')}
placeholder="Email"
/>
{touched.email && errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
State Lifting and Prop Drilling
State Lifting
When multiple components need to share the same state, lift the state up to their common parent.
// Parent component manages shared state
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const addTodo = (text) => {
setTodos(prev => [...prev, {
id: Date.now(),
text,
completed: false
}]);
};
const toggleTodo = (id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const filteredTodos = todos.filter(todo => {
switch (filter) {
case 'active': return !todo.completed;
case 'completed': return todo.completed;
default: return true;
}
});
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoFilter filter={filter} onFilterChange={setFilter} />
<TodoList todos={filteredTodos} onToggle={toggleTodo} />
</div>
);
}
// Child components receive state and handlers as props
function TodoInput({ onAdd }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
);
}
function TodoFilter({ filter, onFilterChange }) {
return (
<div>
<button
className={filter === 'all' ? 'active' : ''}
onClick={() => onFilterChange('all')}
>
All
</button>
<button
className={filter === 'active' ? 'active' : ''}
onClick={() => onFilterChange('active')}
>
Active
</button>
<button
className={filter === 'completed' ? 'active' : ''}
onClick={() => onFilterChange('completed')}
>
Completed
</button>
</div>
);
}
function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
</li>
))}
</ul>
);
}
External State Management Libraries
Introduction to Redux
Redux is a predictable state container for JavaScript apps.
// Redux store setup
import { createStore } from 'redux';
// Action types
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const SET_FILTER = 'SET_FILTER';
// Action creators
const addTodo = (text) => ({
type: ADD_TODO,
payload: { id: Date.now(), text, completed: false }
});
const toggleTodo = (id) => ({
type: TOGGLE_TODO,
payload: id
});
const setFilter = (filter) => ({
type: SET_FILTER,
payload: filter
});
// Reducer
function todoReducer(state = { todos: [], filter: 'all' }, action) {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [...state.todos, action.payload]
};
case TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case SET_FILTER:
return {
...state,
filter: action.payload
};
default:
return state;
}
}
// Create store
const store = createStore(todoReducer);
// React-Redux usage
import { Provider, useSelector, useDispatch } from 'react-redux';
function TodoApp() {
return (
<Provider store={store}>
<TodoContainer />
</Provider>
);
}
function TodoContainer() {
const todos = useSelector(state => state.todos);
const filter = useSelector(state => state.filter);
const dispatch = useDispatch();
const filteredTodos = todos.filter(todo => {
switch (filter) {
case 'active': return !todo.completed;
case 'completed': return todo.completed;
default: return true;
}
});
return (
<div>
<TodoInput onAdd={(text) => dispatch(addTodo(text))} />
<TodoFilter
filter={filter}
onFilterChange={(filter) => dispatch(setFilter(filter))}
/>
<TodoList
todos={filteredTodos}
onToggle={(id) => dispatch(toggleTodo(id))}
/>
</div>
);
}
Zustand (Lightweight Alternative)
Zustand is a small, fast, and scalable state management solution.
import { create } from 'zustand';
// Create store
const useTodoStore = create((set) => ({
todos: [],
filter: 'all',
addTodo: (text) => set((state) => ({
todos: [...state.todos, {
id: Date.now(),
text,
completed: false
}]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
setFilter: (filter) => set({ filter }),
clearCompleted: () => set((state) => ({
todos: state.todos.filter(todo => !todo.completed)
}))
}));
// Usage in components
function TodoApp() {
const { todos, filter, addTodo, toggleTodo, setFilter } = useTodoStore();
const filteredTodos = todos.filter(todo => {
switch (filter) {
case 'active': return !todo.completed;
case 'completed': return todo.completed;
default: return true;
}
});
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoFilter filter={filter} onFilterChange={setFilter} />
<TodoList todos={filteredTodos} onToggle={toggleTodo} />
</div>
);
}
State Management Best Practices
1. Keep State Close to Where It's Used
// Good: State is local to the component that needs it
function UserProfile() {
const [isEditing, setIsEditing] = useState(false);
// ... component logic
}
// Avoid: Lifting state unnecessarily high
function App() {
const [isEditing, setIsEditing] = useState(false); // Only used in UserProfile
return <UserProfile isEditing={isEditing} setIsEditing={setIsEditing} />;
}
2. Use Immutable Updates
// Good: Immutable updates
const addTodo = (text) => {
setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
};
// Bad: Mutating state directly
const addTodo = (text) => {
todos.push({ id: Date.now(), text, completed: false }); // Don't do this!
setTodos(todos);
};
3. Normalize Complex State
// Good: Normalized state structure
const initialState = {
users: {
byId: {
1: { id: 1, name: 'John', email: '[email protected]' },
2: { id: 2, name: 'Jane', email: '[email protected]' }
},
allIds: [1, 2]
},
posts: {
byId: {
1: { id: 1, title: 'Post 1', authorId: 1 },
2: { id: 2, title: 'Post 2', authorId: 2 }
},
allIds: [1, 2]
}
};
// Bad: Nested arrays and objects
const badState = {
users: [
{
id: 1,
name: 'John',
posts: [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' }
]
}
]
};
4. Use Selectors for Derived State
// Good: Use selectors to compute derived state
const useTodoStats = () => {
const todos = useTodoStore(state => state.todos);
return useMemo(() => ({
total: todos.length,
completed: todos.filter(todo => todo.completed).length,
active: todos.filter(todo => !todo.completed).length
}), [todos]);
};
// Usage
function TodoStats() {
const { total, completed, active } = useTodoStats();
return (
<div>
<p>Total: {total}</p>
<p>Completed: {completed}</p>
<p>Active: {active}</p>
</div>
);
}
Practice Project: Task Management App
Let's build a comprehensive task management application using the state management patterns we've learned:
import React, { useState, useReducer, createContext, useContext } from 'react';
// Context for global state
const TaskContext = createContext();
// Initial state
const initialState = {
tasks: [],
categories: ['Work', 'Personal', 'Shopping'],
filter: 'all',
sortBy: 'created',
isLoading: false
};
// Reducer
function taskReducer(state, action) {
switch (action.type) {
case 'ADD_TASK':
return {
...state,
tasks: [...state.tasks, {
id: Date.now(),
...action.payload,
createdAt: new Date().toISOString(),
completed: false
}]
};
case 'TOGGLE_TASK':
return {
...state,
tasks: state.tasks.map(task =>
task.id === action.payload
? { ...task, completed: !task.completed }
: task
)
};
case 'DELETE_TASK':
return {
...state,
tasks: state.tasks.filter(task => task.id !== action.payload)
};
case 'UPDATE_TASK':
return {
...state,
tasks: state.tasks.map(task =>
task.id === action.payload.id
? { ...task, ...action.payload.updates }
: task
)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'SET_SORT':
return { ...state, sortBy: action.payload };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state;
}
}
// Provider
function TaskProvider({ children }) {
const [state, dispatch] = useReducer(taskReducer, initialState);
const actions = {
addTask: (task) => dispatch({ type: 'ADD_TASK', payload: task }),
toggleTask: (id) => dispatch({ type: 'TOGGLE_TASK', payload: id }),
deleteTask: (id) => dispatch({ type: 'DELETE_TASK', payload: id }),
updateTask: (id, updates) => dispatch({
type: 'UPDATE_TASK',
payload: { id, updates }
}),
setFilter: (filter) => dispatch({ type: 'SET_FILTER', payload: filter }),
setSort: (sortBy) => dispatch({ type: 'SET_SORT', payload: sortBy }),
setLoading: (loading) => dispatch({ type: 'SET_LOADING', payload: loading })
};
return (
<TaskContext.Provider value={{ state, actions }}>
{children}
</TaskContext.Provider>
);
}
// Custom hook
function useTasks() {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTasks must be used within a TaskProvider');
}
return context;
}
// Main App
function TaskApp() {
return (
<TaskProvider>
<div className="task-app">
<Header />
<TaskInput />
<TaskFilters />
<TaskList />
<TaskStats />
</div>
</TaskProvider>
);
}
// Components
function Header() {
const { state } = useTasks();
return (
<header>
<h1>Task Manager</h1>
{state.isLoading && <div>Loading...</div>}
</header>
);
}
function TaskInput() {
const { actions, state } = useTasks();
const [formData, setFormData] = useState({
title: '',
description: '',
category: state.categories[0],
priority: 'medium'
});
const handleSubmit = (e) => {
e.preventDefault();
if (formData.title.trim()) {
actions.addTask(formData);
setFormData({
title: '',
description: '',
category: state.categories[0],
priority: 'medium'
});
}
};
return (
<form onSubmit={handleSubmit} className="task-input">
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Task title"
required
/>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Task description"
/>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
>
{state.categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
<select
value={formData.priority}
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Add Task</button>
</form>
);
}
function TaskFilters() {
const { state, actions } = useTasks();
return (
<div className="task-filters">
<div>
<label>Filter:</label>
<select
value={state.filter}
onChange={(e) => actions.setFilter(e.target.value)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
</div>
<div>
<label>Sort by:</label>
<select
value={state.sortBy}
onChange={(e) => actions.setSort(e.target.value)}
>
<option value="created">Created Date</option>
<option value="title">Title</option>
<option value="priority">Priority</option>
</select>
</div>
</div>
);
}
function TaskList() {
const { state, actions } = useTasks();
const filteredAndSortedTasks = state.tasks
.filter(task => {
switch (state.filter) {
case 'active': return !task.completed;
case 'completed': return task.completed;
default: return true;
}
})
.sort((a, b) => {
switch (state.sortBy) {
case 'title': return a.title.localeCompare(b.title);
case 'priority': return ['high', 'medium', 'low'].indexOf(a.priority) -
['high', 'medium', 'low'].indexOf(b.priority);
default: return new Date(b.createdAt) - new Date(a.createdAt);
}
});
return (
<div className="task-list">
{filteredAndSortedTasks.map(task => (
<TaskItem key={task.id} task={task} actions={actions} />
))}
</div>
);
}
function TaskItem({ task, actions }) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({
title: task.title,
description: task.description,
category: task.category,
priority: task.priority
});
const handleSave = () => {
actions.updateTask(task.id, editData);
setIsEditing(false);
};
return (
<div className={`task-item ${task.priority} ${task.completed ? 'completed' : ''}`}>
{isEditing ? (
<div className="task-edit">
<input
value={editData.title}
onChange={(e) => setEditData(prev => ({ ...prev, title: e.target.value }))}
/>
<textarea
value={editData.description}
onChange={(e) => setEditData(prev => ({ ...prev, description: e.target.value }))}
/>
<select
value={editData.category}
onChange={(e) => setEditData(prev => ({ ...prev, category: e.target.value }))}
>
<option value="Work">Work</option>
<option value="Personal">Personal</option>
<option value="Shopping">Shopping</option>
</select>
<select
value={editData.priority}
onChange={(e) => setEditData(prev => ({ ...prev, priority: e.target.value }))}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button onClick={handleSave}>Save</button>
<button onClick={() => setIsEditing(false)}>Cancel</button>
</div>
) : (
<div className="task-content">
<input
type="checkbox"
checked={task.completed}
onChange={() => actions.toggleTask(task.id)}
/>
<div className="task-info">
<h3>{task.title}</h3>
<p>{task.description}</p>
<div className="task-meta">
<span className="category">{task.category}</span>
<span className="priority">{task.priority}</span>
<span className="date">
{new Date(task.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="task-actions">
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={() => actions.deleteTask(task.id)}>Delete</button>
</div>
</div>
)}
</div>
);
}
function TaskStats() {
const { state } = useTasks();
const stats = {
total: state.tasks.length,
completed: state.tasks.filter(task => task.completed).length,
active: state.tasks.filter(task => !task.completed).length,
byCategory: state.categories.reduce((acc, category) => {
acc[category] = state.tasks.filter(task => task.category === category).length;
return acc;
}, {})
};
return (
<div className="task-stats">
<h3>Statistics</h3>
<div className="stats-grid">
<div>Total: {stats.total}</div>
<div>Completed: {stats.completed}</div>
<div>Active: {stats.active}</div>
{Object.entries(stats.byCategory).map(([category, count]) => (
<div key={category}>{category}: {count}</div>
))}
</div>
</div>
);
}
export default TaskApp;
Summary
In this chapter, we explored comprehensive state management in React:
- Local State: useState for simple component state
- Complex State: useReducer for complex state logic
- Global State: Context API for sharing state across components
- Custom Hooks: Reusable state logic
- State Lifting: Moving state up to common parents
- External Libraries: Redux and Zustand for large applications
- Best Practices: Immutable updates, normalization, selectors