State Management
State management is a cornerstone of scalable and maintainable React applications. This practical guide will help you choose the right approach for your needs.
1. React’s Built-in State Management
Section titled “1. React’s Built-in State Management”useState
Section titled “useState”For simple, local component state:
// Counter.jsximport { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
const increment = () => setCount(prevCount => prevCount + 1);
return ( <div> <p>Count: {count}</p> <button onClick={increment}>Increment</button> </div> );}
useReducer
Section titled “useReducer”For complex state logic or when multiple values update together:
// Counter.jsximport { useReducer } from 'react';
function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: throw new Error(`Unsupported action type: ${action.type}`); }}
function Counter() { const [state, dispatch] = useReducer(reducer, { count: 0 });
return ( <div> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> <button onClick={() => dispatch({ type: 'reset' })}>Reset</button> </div> );}
Context API
Section titled “Context API”For sharing state across component trees:
// AuthContext.jsximport { createContext, useContext, useReducer } from 'react';
// Define a reducer for more complex state managementfunction authReducer(state, action) { switch (action.type) { case 'LOGIN': return { ...state, user: action.payload, isAuthenticated: true }; case 'LOGOUT': return { ...state, user: null, isAuthenticated: false }; default: return state; }}
const AuthContext = createContext();
export function AuthProvider({ children }) { const [state, dispatch] = useReducer(authReducer, { user: null, isAuthenticated: false });
// Provide actions as simple functions const login = (userData) => dispatch({ type: 'LOGIN', payload: userData }); const logout = () => dispatch({ type: 'LOGOUT' });
return ( <AuthContext.Provider value={{ ...state, login, logout }}> {children} </AuthContext.Provider> );}
export function useAuth() { const context = useContext(AuthContext); if (context === undefined) { throw new Error('useAuth must be used within an AuthProvider'); } return context;}
2. State Management Best Practices
Section titled “2. State Management Best Practices”Naming Conventions
Section titled “Naming Conventions”Use descriptive names that indicate the purpose and type of state:
// ❌ Poor namingconst [s, setS] = useState(false);const [data, setData] = useState([]);
// ✅ Clear, descriptive namingconst [isLoading, setIsLoading] = useState(false);const [userProfiles, setUserProfiles] = useState([]);const [searchQuery, setSearchQuery] = useState('');
// For boolean states, use "is", "has", or "should" prefixesconst [isVisible, setIsVisible] = useState(false);const [hasError, setHasError] = useState(false);
Managing Multiple Related States
Section titled “Managing Multiple Related States”Problem: Too many individual state variables make components harder to maintain.
// ❌ Too many individual state variablesfunction UserProfile() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); // ...more individual states
// Imagine updating these individually...}
Solution: Group related state into objects.
// ✅ Grouped related statefunction UserProfile() { const [userInfo, setUserInfo] = useState({ firstName: '', lastName: '', email: '', phone: '', // ...other fields }); const [uiState, setUiState] = useState({ isEditing: false, hasError: false });
// Update state immutably const updateUserField = (field, value) => { setUserInfo(prev => ({ ...prev, [field]: value })); };
}
Handling State Updates Correctly
Section titled “Handling State Updates Correctly”Avoid direct state mutation:
// ❌ Direct mutationconst addTask = (task) => { tasks.push(task); // Direct mutation! setTasks(tasks); // Won't trigger re-render correctly};
// ✅ Immutable updatesconst addTask = (task) => { setTasks(prevTasks => [...prevTasks, task]);};
const updateTask = (id, updates) => { setTasks(prevTasks => prevTasks.map(task => task.id === id ? { ...task, ...updates } : task) );};
Proper State Location and Lifting State Up
Section titled “Proper State Location and Lifting State Up”A good practice is to keep the state as close to the components that need it as possible. This approach, known as “lifting state up,” reduces complexity and makes your app easier to reason about.
// ❌ Poor state placement - state too high in the treefunction App() { // This state is only used by SearchBar, but defined in App const [searchTerm, setSearchTerm] = useState('');
return ( <div> <Header /> <SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} /> <Content /> {/* Doesn't use searchTerm */} <Footer /> {/* Doesn't use searchTerm */} </div> );}
// ✅ Proper state placement - state close to where it's usedfunction App() { return ( <div> <Header /> <SearchBar /> <Content /> <Footer /> </div> );}
function SearchBar() { // State is now placed directly in component that uses it const [searchTerm, setSearchTerm] = useState(''); // ...search logic}
If multiple components need access to the same state, lift that state up to their nearest common ancestor. By doing so, you create a single source of truth for that state, making it easier to manage and update.
// ✅ Lifting state up to common ancestorfunction FilterableProductList() { // State lifted to common ancestor of both components that need it const [filterText, setFilterText] = useState('');
return ( <div> <SearchBar filterText={filterText} onFilterChange={setFilterText} /> <ProductList filterText={filterText} /> </div> );}
function SearchBar({ filterText, onFilterChange }) { return ( <input type="text" value={filterText} onChange={(e) => onFilterChange(e.target.value)} placeholder="Search..." /> );}
function ProductList({ filterText }) { // Use filterText to filter products // ...}
Using URL Search Parameters for UI State
Section titled “Using URL Search Parameters for UI State”For filtering, sorting, pagination, and search functionality (especially in e-commerce sites, dashboards, or content browsing applications), prefer storing the state in URL search parameters rather than component state:
// ❌ Filter/pagination stored in component statefunction ProductList() { const [page, setPage] = useState(1); const [filters, setFilters] = useState({ category: '', priceRange: [0, 1000], sortBy: 'popularity' });
// This state is lost on page refresh or when sharing the URL // ...more code...}
// ✅ Filter/pagination stored in URL search paramsimport { useSearchParams } from 'react-router-dom';
function ProductList() { const [searchParams, setSearchParams] = useSearchParams();
// Read values from URL with defaults const page = parseInt(searchParams.get('page') || '1'); const category = searchParams.get('category') || ''; const minPrice = parseInt(searchParams.get('minPrice') || '0'); const maxPrice = parseInt(searchParams.get('maxPrice') || '1000'); const sortBy = searchParams.get('sortBy') || 'popularity';
// Update URL params const updateFilters = (newFilters) => { setSearchParams({ page: page.toString(), ...newFilters }); };
const goToPage = (newPage) => { searchParams.set('page', newPage.toString()); setSearchParams(searchParams); };
// ...fetch data based on URL params...}
Benefits of URL-based state:
- Shareable links - Users can share exact search results or filtered views
- Bookmarking and browser history support
- SEO benefits and persistence on refresh
- Deep linking - Direct access to specific data views
When to Split State vs. Combine It
Section titled “When to Split State vs. Combine It”Split state when:
- Parts of the state update independently
- Different parts of the UI depend on different pieces of state
- You need to optimize rendering performance
Combine state when:
- State variables are updated together
- They represent a logical group
- You need to reset or clone the entire state at once
// ✅ Good state separationfunction SearchableList() { // These update independently and affect different parts of the UI const [searchTerm, setSearchTerm] = useState(''); const [sortOrder, setSortOrder] = useState('asc'); const [items, setItems] = useState([]);
// ...more code...}
// ✅ Good state combinationfunction CheckoutForm() { // These represent a logical group and are often updated together const [billingInfo, setBillingInfo] = useState({ name: '', address: '', cardNumber: '', expiryDate: '', cvv: '' });
const handleChange = (e) => { setBillingInfo(prev => ({ ...prev, [e.target.name]: e.target.value })); };
// ...more code...}
Proper Keys for List State
Section titled “Proper Keys for List State”When managing lists in state, proper key
props are essential for React to efficiently update the DOM and preserve component state correctly.
❌ Never use array index as key:
function TodoList() { const [todos, setTodos] = useState([]);
return ( <ul> {todos.map((todo, index) => ( <li key={index}> {/* ❌ Index keys cause problems */} <input type="checkbox" checked={todo.completed} /> <span>{todo.text}</span> <input type="text" defaultValue={todo.note} /> </li> ))} </ul> );}
Problems with index keys:
- Lost form state: When items are reordered, input values get mixed up
- Performance issues: React can’t optimize updates properly
- Wrong updates: Component state gets attached to wrong items
✅ Always use stable, unique identifiers:
function TodoList() { const [todos, setTodos] = useState([]);
const addTodo = (text) => { setTodos(prev => [...prev, { id: crypto.randomUUID(), // Generate unique ID text, completed: false, note: '' }]); };
return ( <ul> {todos.map((todo) => ( <li key={todo.id}> {/* ✅ Stable unique key */} <input type="checkbox" checked={todo.completed} /> <span>{todo.text}</span> <input type="text" defaultValue={todo.note} /> </li> ))} </ul> );}
Generating keys for list state:
// When adding items to state, always include a unique IDconst addItem = (itemData) => { const newItem = { id: crypto.randomUUID(), // or use nanoid, Date.now(), etc. ...itemData, createdAt: new Date() }; setItems(prev => [...prev, newItem]);};
// For data without IDs, create composite keys (if truly unique)const key = `${item.userId}-${item.timestamp}-${item.type}`;
3. External State Libraries
Section titled “3. External State Libraries”For complex applications, consider these popular libraries:
Library | Size | Learning Curve | Key Features | Best For |
---|---|---|---|---|
Redux | Larger bundle | Steep | Centralized store, middleware, devtools | Large enterprise apps |
Zustand | Tiny (3KB) | Easy | Simple API, hooks-based | Most React apps |
Jotai | Tiny (2.5KB) | Moderate | Atomic approach | Component-focused state |
Zustand Example
Section titled “Zustand Example”// store.jsimport { create } from 'zustand';
const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }),}));
// Component.jsxfunction Counter() { const { count, increment, decrement, reset } = useStore();
return ( <div> <p>Count: {count}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> );}
4. Asynchronous State Management with TanStack Query
Section titled “4. Asynchronous State Management with TanStack Query”TanStack Query v5 (formerly React Query) simplifies fetching, caching, and synchronizing server state:
Key TanStack Query Features
Section titled “Key TanStack Query Features”- Automatic caching - Results are cached by query key
- Background data refreshing - Keep data fresh without interrupting the user
- Automatic retry logic - Handles intermittent failures
- Pagination and infinite scroll - Built-in support
- Mutation helpers - Simplifies CRUD operations
- Devtools - Visualize queries and cache
For more details on its benefits, see the API Integration section of our docs.
5. Form State Management
Section titled “5. Form State Management”Handling form state efficiently is vital for user experience and application performance.
Some of two most popular libraries for managing form state are React Hook Form and Formik, React Hook Form uses uncontrolled components with refs for better performance, while Formik provides a more declarative API with built-in helpers for form state management.
Conclusion
Section titled “Conclusion”Decision Framework
Section titled “Decision Framework”Choose your state management approach based on:
-
Scope: How widely is the state needed?
- Single component →
useState
- Component tree →
useContext
- App-wide → External library
- Single component →
-
Complexity: How complex is your state logic?
- Simple values →
useState
- Complex logic →
useReducer
- Multiple interrelated pieces → External library
- Simple values →
-
Source: Where does the state come from?
- Local UI state → React’s built-in hooks
- Server data → TanStack Query
- Form inputs → React Hook Form / Formik
-
Performance needs: Is rendering performance critical?
- Apply memoization strategically
- Consider atomic state libraries for frequent updates
Remember that the simplest solution that solves your problem is usually the best. Start small and scale your state management approach as your application grows.