Skip to content

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.


For simple, local component state:

// Counter.jsx
import { 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>
);
}

For complex state logic or when multiple values update together:

// Counter.jsx
import { 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>
);
}

For sharing state across component trees:

// AuthContext.jsx
import { createContext, useContext, useReducer } from 'react';
// Define a reducer for more complex state management
function 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;
}

Use descriptive names that indicate the purpose and type of state:

// ❌ Poor naming
const [s, setS] = useState(false);
const [data, setData] = useState([]);
// ✅ Clear, descriptive naming
const [isLoading, setIsLoading] = useState(false);
const [userProfiles, setUserProfiles] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
// For boolean states, use "is", "has", or "should" prefixes
const [isVisible, setIsVisible] = useState(false);
const [hasError, setHasError] = useState(false);

Problem: Too many individual state variables make components harder to maintain.

// ❌ Too many individual state variables
function 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 state
function 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
}));
};
}

Avoid direct state mutation:

// ❌ Direct mutation
const addTask = (task) => {
tasks.push(task); // Direct mutation!
setTasks(tasks); // Won't trigger re-render correctly
};
// ✅ Immutable updates
const 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 tree
function 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 used
function 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 ancestor
function 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
// ...
}

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 state
function 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 params
import { 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

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 separation
function 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 combination
function 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...
}

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 ID
const 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}`;

For complex applications, consider these popular libraries:

LibrarySizeLearning CurveKey FeaturesBest For
ReduxLarger bundleSteepCentralized store, middleware, devtoolsLarge enterprise apps
ZustandTiny (3KB)EasySimple API, hooks-basedMost React apps
JotaiTiny (2.5KB)ModerateAtomic approachComponent-focused state
// store.js
import { 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.jsx
function 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:

  • 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.


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.


Choose your state management approach based on:

  1. Scope: How widely is the state needed?

    • Single component → useState
    • Component tree → useContext
    • App-wide → External library
  2. Complexity: How complex is your state logic?

    • Simple values → useState
    • Complex logic → useReducer
    • Multiple interrelated pieces → External library
  3. 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
  4. 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.