State Management in Modern React Applications
State management is the backbone of any React application, determining how data flows through your components and how your application responds to user interactions. As React applications grow in complexity, choosing the right state management approach becomes crucial for maintainability, performance, and developer experience. This comprehensive guide explores every aspect of modern state management, from React's built-in capabilities to sophisticated third-party solutions.
Understanding State in React Applications
State represents the data that drives your UI at any given moment. It's the single source of truth that determines what users see and how they can interact with your application. Effective state management ensures:
- Predictable UI behavior across all components
- Optimal performance through efficient re-renders
- Maintainable codebase that scales with your team
- Debugging capabilities for complex data flows
- Type safety in TypeScript applications
Types of State in React Applications
Understanding different types of state helps you choose the right management approach:
// 1. Local Component State
function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}
// 2. Shared State (between components)
function App() {
const [user, setUser] = useState(null)
return (
<div>
<Header user={user} />
<Profile user={user} onUpdate={setUser} />
</div>
)
}
// 3. Global Application State
const globalState = {
user: { id: 1, name: 'John' },
theme: 'dark',
notifications: [],
cart: { items: [], total: 0 }
}
// 4. Server State (cached from API)
const serverState = {
posts: { data: [...], loading: false, error: null },
users: { data: [...], loading: true, error: null }
}
// 5. URL State (from routing)
const urlState = {
pathname: '/dashboard',
searchParams: { tab: 'analytics', filter: 'recent' }
}
React's Built-in State Management
useState: The Foundation
useState is perfect for local component state and simple shared state scenarios:
// Basic usage
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all')
const addTodo = (text: string) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date()
}
setTodos(prev => [...prev, newTodo])
}
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
)
}
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed)
case 'completed':
return todos.filter(todo => todo.completed)
default:
return todos
}
}, [todos, filter])
return (
<div>
<TodoInput onAdd={addTodo} />
<FilterButtons filter={filter} onFilterChange={setFilter} />
<TodoItems todos={filteredTodos} onToggle={toggleTodo} />
</div>
)
}
useReducer: Complex State Logic
When state logic becomes complex, useReducer provides better organization:
// Define state and actions
interface TodoState {
todos: Todo[]
filter: 'all' | 'active' | 'completed'
loading: boolean
error: string | null
}
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: number } }
| { type: 'DELETE_TODO'; payload: { id: number } }
| { type: 'SET_FILTER'; payload: { filter: TodoState['filter'] } }
| { type: 'SET_LOADING'; payload: { loading: boolean } }
| { type: 'SET_ERROR'; payload: { error: string | null } }
| { type: 'LOAD_TODOS_SUCCESS'; payload: { todos: Todo[] } }
// Reducer function
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload.text,
completed: false,
createdAt: new Date()
}
]
}
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
}
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
}
case 'SET_FILTER':
return {
...state,
filter: action.payload.filter
}
case 'SET_LOADING':
return {
...state,
loading: action.payload.loading
}
case 'SET_ERROR':
return {
...state,
error: action.payload.error,
loading: false
}
case 'LOAD_TODOS_SUCCESS':
return {
...state,
todos: action.payload.todos,
loading: false,
error: null
}
default:
return state
}
}
// Component using useReducer
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all',
loading: false,
error: null
})
const addTodo = (text: string) => {
dispatch({ type: 'ADD_TODO', payload: { text } })
}
const toggleTodo = (id: number) => {
dispatch({ type: 'TOGGLE_TODO', payload: { id } })
}
const loadTodos = async () => {
dispatch({ type: 'SET_LOADING', payload: { loading: true } })
try {
const todos = await fetchTodos()
dispatch({ type: 'LOAD_TODOS_SUCCESS', payload: { todos } })
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: { error: error.message } })
}
}
useEffect(() => {
loadTodos()
}, [])
return (
<div>
{state.loading && <LoadingSpinner />}
{state.error && <ErrorMessage error={state.error} />}
<TodoList
todos={state.todos}
filter={state.filter}
onAddTodo={addTodo}
onToggleTodo={toggleTodo}
onFilterChange={(filter) =>
dispatch({ type: 'SET_FILTER', payload: { filter } })
}
/>
</div>
)
}
Context API: Sharing State Across Components
React Context provides a way to share state without prop drilling:
// Create typed context
interface AppContextType {
user: User | null
theme: 'light' | 'dark'
notifications: Notification[]
login: (credentials: LoginCredentials) => Promise<void>
logout: () => void
toggleTheme: () => void
addNotification: (notification: Omit<Notification, 'id'>) => void
removeNotification: (id: string) => void
}
const AppContext = createContext<AppContextType | undefined>(undefined)
// Custom hook for using context
export function useAppContext() {
const context = useContext(AppContext)
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider')
}
return context
}
// Provider component
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [notifications, setNotifications] = useState<Notification[]>([])
const login = async (credentials: LoginCredentials) => {
try {
const user = await authService.login(credentials)
setUser(user)
addNotification({
type: 'success',
message: 'Successfully logged in',
duration: 3000
})
} catch (error) {
addNotification({
type: 'error',
message: 'Login failed',
duration: 5000
})
throw error
}
}
const logout = () => {
setUser(null)
authService.logout()
addNotification({
type: 'info',
message: 'You have been logged out',
duration: 3000
})
}
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
const addNotification = (notification: Omit<Notification, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9)
const newNotification = { ...notification, id }
setNotifications(prev => [...prev, newNotification])
if (notification.duration) {
setTimeout(() => {
removeNotification(id)
}, notification.duration)
}
}
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id))
}
const value: AppContextType = {
user,
theme,
notifications,
login,
logout,
toggleTheme,
addNotification,
removeNotification
}
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
)
}
// Usage in components
function Header() {
const { user, theme, toggleTheme, logout } = useAppContext()
return (
<header className={`header ${theme}`}>
<div className="header-content">
<Logo />
<nav>
<ThemeToggle theme={theme} onToggle={toggleTheme} />
{user ? (
<UserMenu user={user} onLogout={logout} />
) : (
<LoginButton />
)}
</nav>
</div>
</header>
)
}
Redux Toolkit: The Modern Redux Experience
Redux Toolkit (RTK) has revolutionized Redux development by reducing boilerplate and promoting best practices:
Setting Up Redux Toolkit
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { authSlice } from './slices/authSlice'
import { todosSlice } from './slices/todosSlice'
import { uiSlice } from './slices/uiSlice'
import { apiSlice } from './slices/apiSlice'
export const store = configureStore({
reducer: {
auth: authSlice.reducer,
todos: todosSlice.reducer,
ui: uiSlice.reducer,
api: apiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
},
}).concat(apiSlice.middleware),
devTools: process.env.NODE_ENV !== 'production',
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
Creating Feature Slices
// store/slices/todosSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
interface TodosState {
items: Todo[]
filter: 'all' | 'active' | 'completed'
loading: boolean
error: string | null
lastUpdated: number | null
}
const initialState: TodosState = {
items: [],
filter: 'all',
loading: false,
error: null,
lastUpdated: null,
}
// Async thunks for API calls
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue }) => {
try {
const response = await todosApi.getAll()
return response.data
} catch (error) {
return rejectWithValue(error.message)
}
}
)
export const createTodo = createAsyncThunk(
'todos/createTodo',
async (todoData: CreateTodoData, { rejectWithValue }) => {
try {
const response = await todosApi.create(todoData)
return response.data
} catch (error) {
return rejectWithValue(error.message)
}
}
)
export const updateTodo = createAsyncThunk(
'todos/updateTodo',
async ({ id, updates }: { id: number; updates: Partial<Todo> }, { rejectWithValue }) => {
try {
const response = await todosApi.update(id, updates)
return response.data
} catch (error) {
return rejectWithValue(error.message)
}
}
)
export const deleteTodo = createAsyncThunk(
'todos/deleteTodo',
async (id: number, { rejectWithValue }) => {
try {
await todosApi.delete(id)
return id
} catch (error) {
return rejectWithValue(error.message)
}
}
)
// Slice definition
export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
state.filter = action.payload
},
clearError: (state) => {
state.error = null
},
toggleTodoLocal: (state, action: PayloadAction<number>) => {
const todo = state.items.find(item => item.id === action.payload)
if (todo) {
todo.completed = !todo.completed
}
},
},
extraReducers: (builder) => {
builder
// Fetch todos
.addCase(fetchTodos.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
state.lastUpdated = Date.now()
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string
})
// Create todo
.addCase(createTodo.fulfilled, (state, action) => {
state.items.push(action.payload)
state.lastUpdated = Date.now()
})
.addCase(createTodo.rejected, (state, action) => {
state.error = action.payload as string
})
// Update todo
.addCase(updateTodo.fulfilled, (state, action) => {
const index = state.items.findIndex(item => item.id === action.payload.id)
if (index !== -1) {
state.items[index] = action.payload
}
state.lastUpdated = Date.now()
})
.addCase(updateTodo.rejected, (state, action) => {
state.error = action.payload as string
})
// Delete todo
.addCase(deleteTodo.fulfilled, (state, action) => {
state.items = state.items.filter(item => item.id !== action.payload)
state.lastUpdated = Date.now()
})
.addCase(deleteTodo.rejected, (state, action) => {
state.error = action.payload as string
})
},
})
export const { setFilter, clearError, toggleTodoLocal } = todosSlice.actions
// Selectors
export const selectTodos = (state: RootState) => state.todos.items
export const selectTodosFilter = (state: RootState) => state.todos.filter
export const selectTodosLoading = (state: RootState) => state.todos.loading
export const selectTodosError = (state: RootState) => state.todos.error
export const selectFilteredTodos = (state: RootState) => {
const todos = selectTodos(state)
const filter = selectTodosFilter(state)
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed)
case 'completed':
return todos.filter(todo => todo.completed)
default:
return todos
}
}
export const selectTodosStats = (state: RootState) => {
const todos = selectTodos(state)
return {
total: todos.length,
completed: todos.filter(todo => todo.completed).length,
active: todos.filter(todo => !todo.completed).length,
}
}
RTK Query for Server State
// store/slices/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
}),
tagTypes: ['Todo', 'User', 'Project'],
endpoints: (builder) => ({
// Todos endpoints
getTodos: builder.query<Todo[], void>({
query: () => '/todos',
providesTags: ['Todo'],
}),
getTodo: builder.query<Todo, number>({
query: (id) => `/todos/${id}`,
providesTags: (result, error, id) => [{ type: 'Todo', id }],
}),
createTodo: builder.mutation<Todo, CreateTodoData>({
query: (newTodo) => ({
url: '/todos',
method: 'POST',
body: newTodo,
}),
invalidatesTags: ['Todo'],
}),
updateTodo: builder.mutation<Todo, { id: number; updates: Partial<Todo> }>({
query: ({ id, updates }) => ({
url: `/todos/${id}`,
method: 'PATCH',
body: updates,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Todo', id }],
}),
deleteTodo: builder.mutation<void, number>({
query: (id) => ({
url: `/todos/${id}`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{ type: 'Todo', id }],
}),
// Users endpoints
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'],
}),
getUser: builder.query<User, string>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
}),
})
export const {
useGetTodosQuery,
useGetTodoQuery,
useCreateTodoMutation,
useUpdateTodoMutation,
useDeleteTodoMutation,
useGetUsersQuery,
useGetUserQuery,
} = apiSlice
Using Redux in Components
// hooks/redux.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from '../store'
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// components/TodoList.tsx
import { useAppSelector, useAppDispatch } from '../hooks/redux'
import {
fetchTodos,
createTodo,
updateTodo,
deleteTodo,
setFilter,
selectFilteredTodos,
selectTodosLoading,
selectTodosError,
selectTodosStats
} from '../store/slices/todosSlice'
function TodoList() {
const dispatch = useAppDispatch()
const todos = useAppSelector(selectFilteredTodos)
const loading = useAppSelector(selectTodosLoading)
const error = useAppSelector(selectTodosError)
const stats = useAppSelector(selectTodosStats)
const filter = useAppSelector(state => state.todos.filter)
useEffect(() => {
dispatch(fetchTodos())
}, [dispatch])
const handleCreateTodo = async (text: string) => {
try {
await dispatch(createTodo({ text })).unwrap()
} catch (error) {
console.error('Failed to create todo:', error)
}
}
const handleToggleTodo = async (id: number) => {
const todo = todos.find(t => t.id === id)
if (todo) {
try {
await dispatch(updateTodo({
id,
updates: { completed: !todo.completed }
})).unwrap()
} catch (error) {
console.error('Failed to update todo:', error)
}
}
}
const handleDeleteTodo = async (id: number) => {
try {
await dispatch(deleteTodo(id)).unwrap()
} catch (error) {
console.error('Failed to delete todo:', error)
}
}
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
return (
<div className="todo-list">
<TodoStats stats={stats} />
<TodoInput onSubmit={handleCreateTodo} />
<TodoFilters
currentFilter={filter}
onFilterChange={(filter) => dispatch(setFilter(filter))}
/>
<div className="todos">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => handleToggleTodo(todo.id)}
onDelete={() => handleDeleteTodo(todo.id)}
/>
))}
</div>
</div>
)
}
Zustand: Lightweight State Management
Zustand offers a minimalist approach to state management with excellent TypeScript support:
// store/useStore.ts
import { create } from 'zustand'
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
interface Todo {
id: number
text: string
completed: boolean
createdAt: Date
}
interface TodoStore {
// State
todos: Todo[]
filter: 'all' | 'active' | 'completed'
loading: boolean
error: string | null
// Actions
addTodo: (text: string) => void
toggleTodo: (id: number) => void
deleteTodo: (id: number) => void
setFilter: (filter: 'all' | 'active' | 'completed') => void
clearCompleted: () => void
// Async actions
fetchTodos: () => Promise<void>
saveTodo: (todo: Omit<Todo, 'id' | 'createdAt'>) => Promise<void>
// Computed values
filteredTodos: () => Todo[]
stats: () => { total: number; completed: number; active: number }
}
export const useTodoStore = create<TodoStore>()(
devtools(
persist(
subscribeWithSelector(
immer((set, get) => ({
// Initial state
todos: [],
filter: 'all',
loading: false,
error: null,
// Actions
addTodo: (text) => {
set((state) => {
state.todos.push({
id: Date.now(),
text,
completed: false,
createdAt: new Date(),
})
})
},
toggleTodo: (id) => {
set((state) => {
const todo = state.todos.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
})
},
deleteTodo: (id) => {
set((state) => {
state.todos = state.todos.filter(t => t.id !== id)
})
},
setFilter: (filter) => {
set({ filter })
},
clearCompleted: () => {
set((state) => {
state.todos = state.todos.filter(t => !t.completed)
})
},
// Async actions
fetchTodos: async () => {
set({ loading: true, error: null })
try {
const todos = await todosApi.getAll()
set({ todos, loading: false })
} catch (error) {
set({ error: error.message, loading: false })
}
},
saveTodo: async (todoData) => {
set({ loading: true })
try {
const todo = await todosApi.create(todoData)
set((state) => {
state.todos.push(todo)
state.loading = false
})
} catch (error) {
set({ error: error.message, loading: false })
}
},
// Computed values
filteredTodos: () => {
const { todos, filter } = get()
switch (filter) {
case 'active':
return todos.filter(t => !t.completed)
case 'completed':
return todos.filter(t => t.completed)
default:
return todos
}
},
stats: () => {
const todos = get().todos
return {
total: todos.length,
completed: todos.filter(t => t.completed).length,
active: todos.filter(t => !t.completed).length,
}
},
}))
),
{
name: 'todo-storage',
partialize: (state) => ({
todos: state.todos,
filter: state.filter
}),
}
),
{ name: 'todo-store' }
)
)
// Selectors for optimized re-renders
export const useTodos = () => useTodoStore(state => state.filteredTodos())
export const useTodoStats = () => useTodoStore(state => state.stats())
export const useTodoActions = () => useTodoStore(state => ({
addTodo: state.addTodo,
toggleTodo: state.toggleTodo,
deleteTodo: state.deleteTodo,
setFilter: state.setFilter,
clearCompleted: state.clearCompleted,
fetchTodos: state.fetchTodos,
}))
Using Zustand in Components
// components/TodoApp.tsx
function TodoApp() {
const todos = useTodos()
const stats = useTodoStats()
const { addTodo, toggleTodo, deleteTodo, setFilter, fetchTodos } = useTodoActions()
const loading = useTodoStore(state => state.loading)
const error = useTodoStore(state => state.error)
const filter = useTodoStore(state => state.filter)
useEffect(() => {
fetchTodos()
}, [fetchTodos])
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
return (
<div className="todo-app">
<TodoHeader stats={stats} />
<TodoInput onSubmit={addTodo} />
<TodoFilters currentFilter={filter} onFilterChange={setFilter} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
)
}
// Subscription to state changes
function TodoNotifications() {
useEffect(() => {
const unsubscribe = useTodoStore.subscribe(
(state) => state.todos,
(todos, prevTodos) => {
if (todos.length > prevTodos.length) {
toast.success('Todo added successfully!')
}
}
)
return unsubscribe
}, [])
return null
}
Jotai: Atomic State Management
Jotai provides a bottom-up approach to state management using atomic values:
// atoms/todoAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage, atomWithReset } from 'jotai/utils'
// Base atoms
export const todosAtom = atomWithStorage<Todo[]>('todos', [])
export const filterAtom = atom<'all' | 'active' | 'completed'>('all')
export const loadingAtom = atom(false)
export const errorAtom = atomWithReset<string | null>(null)
// Derived atoms
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom)
const filter = get(filterAtom)
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed)
case 'completed':
return todos.filter(todo => todo.completed)
default:
return todos
}
})
export const todoStatsAtom = atom((get) => {
const todos = get(todosAtom)
return {
total: todos.length,
completed: todos.filter(todo => todo.completed).length,
active: todos.filter(todo => !todo.completed).length,
}
})
// Write-only atoms for actions
export const addTodoAtom = atom(
null,
(get, set, text: string) => {
const todos = get(todosAtom)
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date(),
}
set(todosAtom, [...todos, newTodo])
}
)
export const toggleTodoAtom = atom(
null,
(get, set, id: number) => {
const todos = get(todosAtom)
set(
todosAtom,
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
}
)
export const deleteTodoAtom = atom(
null,
(get, set, id: number) => {
const todos = get(todosAtom)
set(todosAtom, todos.filter(todo => todo.id !== id))
}
)
// Async atoms
export const fetchTodosAtom = atom(
null,
async (get, set) => {
set(loadingAtom, true)
set(errorAtom, null)
try {
const todos = await todosApi.getAll()
set(todosAtom, todos)
} catch (error) {
set(errorAtom, error.message)
} finally {
set(loadingAtom, false)
}
}
)
export const saveTodoAtom = atom(
null,
async (get, set, todoData: Omit<Todo, 'id' | 'createdAt'>) => {
set(loadingAtom, true)
try {
const todo = await todosApi.create(todoData)
const todos = get(todosAtom)
set(todosAtom, [...todos, todo])
} catch (error) {
set(errorAtom, error.message)
} finally {
set(loadingAtom, false)
}
}
)
Using Jotai in Components
// components/TodoApp.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import {
filteredTodosAtom,
todoStatsAtom,
filterAtom,
loadingAtom,
errorAtom,
addTodoAtom,
toggleTodoAtom,
deleteTodoAtom,
fetchTodosAtom,
} from '../atoms/todoAtoms'
function TodoApp() {
const todos = useAtomValue(filteredTodosAtom)
const stats = useAtomValue(todoStatsAtom)
const [filter, setFilter] = useAtom(filterAtom)
const loading = useAtomValue(loadingAtom)
const error = useAtomValue(errorAtom)
const addTodo = useSetAtom(addTodoAtom)
const toggleTodo = useSetAtom(toggleTodoAtom)
const deleteTodo = useSetAtom(deleteTodoAtom)
const fetchTodos = useSetAtom(fetchTodosAtom)
useEffect(() => {
fetchTodos()
}, [fetchTodos])
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
return (
<div className="todo-app">
<TodoHeader stats={stats} />
<TodoInput onSubmit={addTodo} />
<TodoFilters currentFilter={filter} onFilterChange={setFilter} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
)
}
// Optimized component that only re-renders when specific atoms change
function TodoStats() {
const stats = useAtomValue(todoStatsAtom)
return (
<div className="todo-stats">
<span>Total: {stats.total}</span>
<span>Active: {stats.active}</span>
<span>Completed: {stats.completed}</span>
</div>
)
}
Server State Management
TanStack Query (React Query)
For server state management, TanStack Query is the gold standard:
// hooks/useTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { todosApi } from '../api/todos'
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: todosApi.getAll,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
})
}
export function useTodo(id: number) {
return useQuery({
queryKey: ['todos', id],
queryFn: () => todosApi.getById(id),
enabled: !!id,
})
}
export function useCreateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: todosApi.create,
onSuccess: (newTodo) => {
// Update the todos list
queryClient.setQueryData(['todos'], (old: Todo[] = []) => [
...old,
newTodo,
])
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error) => {
console.error('Failed to create todo:', error)
},
})
}
export function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Partial<Todo> }) =>
todosApi.update(id, updates),
onMutate: async ({ id, updates }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
await queryClient.cancelQueries({ queryKey: ['todos', id] })
// Snapshot previous values
const previousTodos = queryClient.getQueryData(['todos'])
const previousTodo = queryClient.getQueryData(['todos', id])
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo => todo.id === id ? { ...todo, ...updates } : todo)
)
queryClient.setQueryData(['todos', id], (old: Todo) =>
old ? { ...old, ...updates } : old
)
return { previousTodos, previousTodo }
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
if (context?.previousTodo) {
queryClient.setQueryData(['todos', variables.id], context.previousTodo)
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
export function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: todosApi.delete,
onSuccess: (_, deletedId) => {
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.filter(todo => todo.id !== deletedId)
)
queryClient.removeQueries({ queryKey: ['todos', deletedId] })
},
})
}
// Infinite query for pagination
export function useInfiniteTodos() {
return useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam = 1 }) => todosApi.getPaginated(pageParam),
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length + 1 : undefined
},
staleTime: 5 * 60 * 1000,
})
}
SWR Alternative
// hooks/useTodosSWR.ts
import useSWR, { mutate } from 'swr'
import useSWRMutation from 'swr/mutation'
const fetcher = (url: string) => fetch(url).then(res => res.json())
export function useTodos() {
const { data, error, isLoading } = useSWR('/api/todos', fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: true,
refreshInterval: 30000, // 30 seconds
})
return {
todos: data,
loading: isLoading,
error,
}
}
export function useCreateTodo() {
const { trigger, isMutating } = useSWRMutation(
'/api/todos',
async (url, { arg }: { arg: CreateTodoData }) => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arg),
})
return response.json()
},
{
onSuccess: (data) => {
// Optimistic update
mutate('/api/todos', (todos: Todo[] = []) => [...todos, data], false)
},
}
)
return {
createTodo: trigger,
isCreating: isMutating,
}
}
Advanced State Management Patterns
State Machines with XState
// machines/todoMachine.ts
import { createMachine, assign } from 'xstate'
interface TodoContext {
todos: Todo[]
error: string | null
filter: 'all' | 'active' | 'completed'
}
type TodoEvent =
| { type: 'FETCH' }
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number }
| { type: 'SET_FILTER'; filter: 'all' | 'active' | 'completed' }
| { type: 'SUCCESS'; data: Todo[] }
| { type: 'FAILURE'; error: string }
export const todoMachine = createMachine<TodoContext, TodoEvent>({
id: 'todos',
initial: 'idle',
context: {
todos: [],
error: null,
filter: 'all',
},
states: {
idle: {
on: {
FETCH: 'loading',
ADD_TODO: {
actions: assign({
todos: (context, event) => [
...context.todos,
{
id: Date.now(),
text: event.text,
completed: false,
createdAt: new Date(),
},
],
}),
},
TOGGLE_TODO: {
actions: assign({
todos: (context, event) =>
context.todos.map(todo =>
todo.id === event.id
? { ...todo, completed: !todo.completed }
: todo
),
}),
},
DELETE_TODO: {
actions: assign({
todos: (context, event) =>
context.todos.filter(todo => todo.id !== event.id),
}),
},
SET_FILTER: {
actions: assign({
filter: (_, event) => event.filter,
}),
},
},
},
loading: {
invoke: {
src: 'fetchTodos',
onDone: {
target: 'idle',
actions: assign({
todos: (_, event) => event.data,
error: null,
}),
},
onError: {
target: 'idle',
actions: assign({
error: (_, event) => event.data.message,
}),
},
},
},
},
}, {
services: {
fetchTodos: () => todosApi.getAll(),
},
})
Compound State Pattern
// hooks/useCompoundState.ts
import { useReducer, useCallback } from 'react'
interface CompoundState<T> {
data: T | null
loading: boolean
error: string | null
lastUpdated: number | null
}
type CompoundAction<T> =
| { type: 'LOADING' }
| { type: 'SUCCESS'; payload: T }
| { type: 'ERROR'; payload: string }
| { type: 'RESET' }
function compoundReducer<T>(
state: CompoundState<T>,
action: CompoundAction<T>
): CompoundState<T> {
switch (action.type) {
case 'LOADING':
return {
...state,
loading: true,
error: null,
}
case 'SUCCESS':
return {
data: action.payload,
loading: false,
error: null,
lastUpdated: Date.now(),
}
case 'ERROR':
return {
...state,
loading: false,
error: action.payload,
}
case 'RESET':
return {
data: null,
loading: false,
error: null,
lastUpdated: null,
}
default:
return state
}
}
export function useCompoundState<T>() {
const [state, dispatch] = useReducer(compoundReducer<T>, {
data: null,
loading: false,
error: null,
lastUpdated: null,
})
const execute = useCallback(async (asyncFn: () => Promise<T>) => {
dispatch({ type: 'LOADING' })
try {
const result = await asyncFn()
dispatch({ type: 'SUCCESS', payload: result })
return result
} catch (error) {
dispatch({ type: 'ERROR', payload: error.message })
throw error
}
}, [])
const reset = useCallback(() => {
dispatch({ type: 'RESET' })
}, [])
return {
...state,
execute,
reset,
}
}
// Usage
function TodoList() {
const { data: todos, loading, error, execute } = useCompoundState<Todo[]>()
const fetchTodos = useCallback(() => {
return execute(() => todosApi.getAll())
}, [execute])
useEffect(() => {
fetchTodos()
}, [fetchTodos])
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!todos) return null
return (
<div>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
)
}
Performance Optimization Strategies
Preventing Unnecessary Re-renders
// Memoization strategies
const TodoItem = React.memo(({ todo, onToggle, onDelete }: TodoItemProps) => {
const handleToggle = useCallback(() => {
onToggle(todo.id)
}, [todo.id, onToggle])
const handleDelete = useCallback(() => {
onDelete(todo.id)
}, [todo.id, onDelete])
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={handleToggle}
/>
<span>{todo.text}</span>
<button onClick={handleDelete}>Delete</button>
</div>
)
})
// Selector optimization
const selectTodoById = (id: number) => (state: RootState) =>
state.todos.items.find(todo => todo.id === id)
function TodoItem({ id }: { id: number }) {
const todo = useAppSelector(selectTodoById(id))
// Component only re-renders when this specific todo changes
}
// State normalization
interface NormalizedTodosState {
byId: Record<number, Todo>
allIds: number[]
filter: 'all' | 'active' | 'completed'
}
const todosAdapter = createEntityAdapter<Todo>()
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState({
filter: 'all' as const,
}),
reducers: {
addTodo: todosAdapter.addOne,
updateTodo: todosAdapter.updateOne,
removeTodo: todosAdapter.removeOne,
setFilter: (state, action) => {
state.filter = action.payload
},
},
})
// Selectors
export const {
selectAll: selectAllTodos,
selectById: selectTodoById,
selectIds: selectTodoIds,
} = todosAdapter.getSelectors((state: RootState) => state.todos)
Virtual Scrolling for Large Lists
// components/VirtualTodoList.tsx
import { FixedSizeList as List } from 'react-window'
interface VirtualTodoListProps {
todos: Todo[]
onToggle: (id: number) => void
onDelete: (id: number) => void
}
const TodoRow = ({ index, style, data }: any) => {
const { todos, onToggle, onDelete } = data
const todo = todos[index]
return (
<div style={style}>
<TodoItem
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
</div>
)
}
export function VirtualTodoList({ todos, onToggle, onDelete }: VirtualTodoListProps) {
return (
<List
height={600}
itemCount={todos.length}
itemSize={60}
itemData={{ todos, onToggle, onDelete }}
>
{TodoRow}
</List>
)
}
Testing State Management
Testing Redux Slices
// __tests__/todosSlice.test.ts
import { configureStore } from '@reduxjs/toolkit'
import { todosSlice, addTodo, toggleTodo, setFilter } from '../store/slices/todosSlice'
describe('todosSlice', () => {
let store: ReturnType<typeof configureStore>
beforeEach(() => {
store = configureStore({
reducer: {
todos: todosSlice.reducer,
},
})
})
it('should add a todo', () => {
const todoText = 'Test todo'
store.dispatch(addTodo(todoText))
const state = store.getState().todos
expect(state.items).toHaveLength(1)
expect(state.items[0].text).toBe(todoText)
expect(state.items[0].completed).toBe(false)
})
it('should toggle a todo', () => {
store.dispatch(addTodo('Test todo'))
const todoId = store.getState().todos.items[0].id
store.dispatch(toggleTodo(todoId))
const state = store.getState().todos
expect(state.items[0].completed).toBe(true)
})
it('should filter todos correctly', () => {
store.dispatch(addTodo('Todo 1'))
store.dispatch(addTodo('Todo 2'))
store.dispatch(toggleTodo(store.getState().todos.items[0].id))
store.dispatch(setFilter('completed'))
const state = store.getState().todos
expect(state.filter).toBe('completed')
})
})
Testing Custom Hooks
// __tests__/useTodos.test.ts
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useTodos, useCreateTodo } from '../hooks/useTodos'
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
describe('useTodos', () => {
it('should fetch todos', async () => {
const { result, waitFor } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(result.current.data).toBeDefined()
})
it('should create a todo', async () => {
const { result } = renderHook(() => useCreateTodo(), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.mutate({ text: 'New todo' })
})
expect(result.current.isSuccess).toBe(true)
})
})
Conclusion
State management in modern React applications is a nuanced discipline that requires understanding the trade-offs between different approaches. The key is choosing the right tool for the right job:
- Built-in React state for local component state and simple shared state
- Redux Toolkit for complex global state with time-travel debugging needs
- Zustand for lightweight global state with minimal boilerplate
- Jotai for atomic, bottom-up state management
- TanStack Query/SWR for server state management
- XState for complex state machines and workflows
The future of React state management continues to evolve with new patterns like Server Components, which blur the lines between client and server state. However, the fundamental principles remain the same: keep state as local as possible, use the right abstraction for your use case, and prioritize developer experience and maintainability.
Remember that state management is not just about choosing a library—it's about designing your application's data flow in a way that scales with your team and requirements. Start simple, measure performance, and evolve your approach as your application grows in complexity.
Resources
- React Documentation - State Management
- Redux Toolkit Documentation
- Zustand Documentation
- Jotai Documentation
- TanStack Query Documentation
- XState Documentation
Mastering state management is essential for building scalable React applications. The patterns and techniques covered in this guide will serve as a foundation for creating maintainable, performant applications that can grow with your needs.
About Tridip Dutta
Creative Developer passionate about creating innovative digital experiences and exploring AI. I love sharing knowledge to help developers build better apps.
