State Management Overview
State management is about organizing and coordinating data across your application. The right approach depends on your app’s complexity and requirements.Local State
Component-specific state with useState and useReducer
Global State
App-wide state with Context API or state libraries
Server State
Remote data with caching and synchronization
URL State
State reflected in URL parameters and routes
Types of State
- Local Component State
- Lifted State
- Global State
- Server State
Local Component State
State that belongs to a single component and doesn’t need to be shared.When to use:- Form input values
- UI toggles (modals, dropdowns)
- Component-specific temporary data
function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Validate
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit
setIsSubmitting(true);
try {
await submitContactForm(formData);
// Reset form on success
setFormData({ name: '', email: '', message: '' });
toast.success('Message sent!');
} catch (error) {
toast.error('Failed to send message');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<Input
label="Name"
value={formData.name}
onChange={(value) => handleChange('name', value)}
error={errors.name}
/>
<Input
label="Email"
type="email"
value={formData.email}
onChange={(value) => handleChange('email', value)}
error={errors.email}
/>
<TextArea
label="Message"
value={formData.message}
onChange={(value) => handleChange('message', value)}
error={errors.message}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</form>
);
}
Keep state as local as possible. Only lift state up when multiple components need access to it.
Lifted State
State moved to a common ancestor when multiple components need to share it.When to use:- Coordinating between sibling components
- Parent needs to control child behavior
- Multiple components read/write same data
// Parent component manages shared state
function ProductFilters() {
const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 });
const [category, setCategory] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortOption>('price-asc');
// Combine all filters
const filters = useMemo(() => ({
priceRange,
category,
sortBy,
}), [priceRange, category, sortBy]);
return (
<div className="product-page">
<FilterSidebar
priceRange={priceRange}
onPriceRangeChange={setPriceRange}
category={category}
onCategoryChange={setCategory}
/>
<div className="product-content">
<SortControl value={sortBy} onChange={setSortBy} />
<ProductList filters={filters} />
</div>
</div>
);
}
// Child components receive state via props
function FilterSidebar({
priceRange,
onPriceRangeChange,
category,
onCategoryChange,
}: FilterSidebarProps) {
return (
<aside>
<PriceRangeSlider
value={priceRange}
onChange={onPriceRangeChange}
/>
<CategorySelect
value={category}
onChange={onCategoryChange}
/>
</aside>
);
}
Lifting state is simple and explicit, but can lead to prop drilling. Use context for deeply nested state.
Global State
Application-wide state accessible from any component.When to use:- User authentication status
- Theme preferences
- Shopping cart contents
- App-wide notifications
// Using Context API for global state
interface AuthContextValue {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
updateProfile: (data: Partial<User>) => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for existing session on mount
authService.getCurrentUser()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setIsLoading(false));
}, []);
const login = async (email: string, password: string) => {
const user = await authService.login(email, password);
setUser(user);
};
const logout = async () => {
await authService.logout();
setUser(null);
};
const updateProfile = async (data: Partial<User>) => {
const updatedUser = await authService.updateProfile(data);
setUser(updatedUser);
};
const value = {
user,
isLoading,
login,
logout,
updateProfile,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// Custom hook for consuming auth context
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Usage in components
function UserMenu() {
const { user, logout } = useAuth();
if (!user) return <LoginButton />;
return (
<div className="user-menu">
<Avatar src={user.avatar} alt={user.name} />
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
For complex global state, consider dedicated libraries like Zustand, Redux Toolkit, or Jotai.
Server State
Data fetched from APIs with caching, background updates, and optimistic updates.When to use:- API data that changes server-side
- Data shared across components
- Need for caching and background sync
// Using React Query for server state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetch data with automatic caching
function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: () => api.get('/projects'),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});
}
// Mutations with optimistic updates
function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Project> }) =>
api.patch(`/projects/${id}`, data),
// Optimistic update
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(['projects']);
// Snapshot previous value
const previous = queryClient.getQueryData(['projects']);
// Optimistically update cache
queryClient.setQueryData(['projects'], (old: Project[]) =>
old.map(p => p.id === id ? { ...p, ...data } : p)
);
return { previous };
},
// Rollback on error
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(['projects'], context.previous);
}
},
// Refetch on success
onSuccess: () => {
queryClient.invalidateQueries(['projects']);
},
});
}
// Usage in component
function ProjectList() {
const { data: projects, isLoading, error } = useProjects();
const updateProject = useUpdateProject();
const handleRename = (id: string, name: string) => {
updateProject.mutate({ id, data: { name } });
};
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onRename={(name) => handleRename(project.id, name)}
/>
))}
</div>
);
}
Libraries like React Query and SWR handle caching, revalidation, and race conditions automatically. Don’t reinvent the wheel!
Data Flow Patterns
Unidirectional Data Flow
Data flows down through props, events flow up through callbacks.// Top-level state
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
const newTodo = { id: generateId(), text, completed: false };
setTodos(prev => [...prev, newTodo]);
};
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// Data flows down, events flow up
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
// Child receives data and callbacks
function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => onToggle(todo.id)}
onDelete={() => onDelete(todo.id)}
/>
))}
</ul>
);
}
Unidirectional data flow makes your app predictable and easier to debug. Data changes always originate from a single source.
State Management Libraries
Zustand - Simple and Flexible
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (product: Product, quantity: number) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
total: number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (product, quantity) => set((state) => {
const existingItem = state.items.find(i => i.product.id === product.id);
if (existingItem) {
return {
items: state.items.map(i =>
i.product.id === product.id
? { ...i, quantity: i.quantity + quantity }
: i
),
};
}
return {
items: [...state.items, { product, quantity }],
};
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter(i => i.product.id !== productId),
})),
updateQuantity: (productId, quantity) => set((state) => ({
items: state.items.map(i =>
i.product.id === productId ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
);
},
}));
// Usage in components
function Cart() {
const items = useCartStore(state => state.items);
const total = useCartStore(state => state.total);
const removeItem = useCartStore(state => state.removeItem);
return (
<div className="cart">
{items.map(item => (
<CartItem
key={item.product.id}
item={item}
onRemove={() => removeItem(item.product.id)}
/>
))}
<div className="cart-total">Total: ${total}</div>
</div>
);
}
Redux Toolkit - Powerful and Opinionated
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TodosState {
items: Todo[];
filter: 'all' | 'active' | 'completed';
}
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
} as TodosState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({
id: generateId(),
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, setFilter } = todosSlice.actions;
const store = configureStore({
reducer: {
todos: todosSlice.reducer,
},
});
// Usage with hooks
function TodoApp() {
const dispatch = useDispatch();
const todos = useSelector((state: RootState) => state.todos.items);
const filter = useSelector((state: RootState) => state.todos.filter);
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<div>
<FilterButtons
current={filter}
onChange={(f) => dispatch(setFilter(f))}
/>
<TodoList
todos={filteredTodos}
onToggle={(id) => dispatch(toggleTodo(id))}
/>
</div>
);
}
Best Practices
1. Choose the Right State Location
1. Choose the Right State Location
- Start with local state
- Lift state only when necessary
- Use context for truly global data
- Use server state libraries for API data
2. Keep State Normalized
2. Keep State Normalized
// Bad: Nested duplication
const state = {
posts: [
{ id: '1', title: 'Post 1', author: { id: 'a', name: 'Alice' } },
{ id: '2', title: 'Post 2', author: { id: 'a', name: 'Alice' } },
],
};
// Good: Normalized with references
const state = {
posts: {
byId: {
'1': { id: '1', title: 'Post 1', authorId: 'a' },
'2': { id: '2', title: 'Post 2', authorId: 'a' },
},
allIds: ['1', '2'],
},
users: {
byId: {
'a': { id: 'a', name: 'Alice' },
},
},
};
3. Use Derived State
3. Use Derived State
// Don't store computed values
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// Bad: Storing derived state
// const [completedCount, setCompletedCount] = useState(0);
// Good: Computing on demand
const completedCount = todos.filter(t => t.completed).length;
const activeCount = todos.length - completedCount;
return (
<div>
<p>{activeCount} active</p>
<p>{completedCount} completed</p>
</div>
);
}
4. Batch State Updates
4. Batch State Updates
// React 18+ automatically batches updates
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// Only one re-render!
}
// For async batching, use startTransition
function handleAsync() {
startTransition(() => {
setQuery(value);
setResults(data);
setLoading(false);
});
}
State management is about trade-offs. Choose simplicity over complexity until you have a specific need for more sophisticated patterns.