State management is one of the most challenging aspects of building complex frontend applications. This guide covers everything you need to know about managing state in modern web applications.
Types of State
Local State
Local state lives in a single component:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Component State
State shared between components:
function Parent() {
const [value, setValue] = useState('');
return (
<>
<Input value={value} onChange={setValue} />
<Display value={value} />
</>
);
}
Application State
Global state shared across the app:
// Redux store
const store = createStore(reducer);
function App() {
const state = useSelector(state => state);
const dispatch = useDispatch();
return <div>{state.user.name}</div>;
}
Local State Patterns
useState Best Practices
// Simple state
const [count, setCount] = useState(0);
// Complex state - use reducer
const [state, dispatch] = useReducer(reducer, initialState);
// Lazy initialization
const [data, setData] = useState(() => {
return expensiveInitialCalculation();
});
// Functional updates
setCount(prev => prev + 1);
useReducer for Complex State
const initialState = {
user: null,
loading: false,
error: null
};
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, user: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function UserProfile() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetchUser()
.then(user => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
}, []);
if (state.loading) return <Spinner />;
if (state.error) return <Error message={state.error} />;
return <div>{state.user.name}</div>;
}
Global State Solutions
Context API
// Create context
const ThemeContext = createContext();
// Provider
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// Consumer
function Header() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<header className={theme}>
<button onClick={toggleTheme}>Toggle Theme</button>
</header>
);
}
Redux
// Store setup
import { configureStore, createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], total: 0 },
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
state.total += action.payload.price;
},
removeItem: (state, action) => {
const index = state.items.findIndex(item => item.id === action.payload);
if (index !== -1) {
state.total -= state.items[index].price;
state.items.splice(index, 1);
}
}
}
});
export const { addItem, removeItem } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
// Usage with hooks
function Cart() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
return (
<div>
{items.map(item => (
<div key={item.id}>
{item.name} - ${item.price}
<button onClick={() => dispatch(removeItem(item.id))}>
Remove
</button>
</div>
))}
<div>Total: ${total}</div>
</div>
);
}
Zustand
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null })
}));
function Profile() {
const { user, logout } = useStore();
return user ? (
<div>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Logout</button>
</div>
) : (
<p>Please login</p>
);
}
Jotai (Atomic State)
import { atom, useAtom } from 'jotai';
// Primitive atoms
const countAtom = atom(0);
const userAtom = atom(null);
// Derived atoms
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// Compound atom
const userNameAtom = atom(
(get) => get(userAtom)?.name ?? 'Guest'
);
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubleCount] = useAtom(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
Server State
React Query (TanStack Query)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 3
});
if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function AddUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
return (
<form onSubmit={mutation.mutate}>
<input name="name" />
<button type="submit" disabled={mutation.isPending}>
Add User
</button>
</form>
);
}
SWR
import useSWR from 'swr';
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher);
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <div>{data.name}</div>;
}
State Management Best Practices
Do’s
// DO: Keep state as local as possible
function Form() {
const [value, setValue] = useState('');
// Only lift state if needed by siblings
}
// DO: Use appropriate tool for the job
// - useState: simple local state
// - useReducer: complex state logic
// - Context: theme, auth, locale
// - Redux/Zustand: complex global state
// - React Query: server state
Don’ts
// DON'T: Over-engineer with Redux for simple apps
// If you have 5 components sharing state, maybe context is enough
// DON'T: Put everything in global state
// Keep derived/computed values as selectors
// DON'T: Forget to normalize nested state
// Flat state is easier to update
Performance Optimization
Memoization
// useMemo for expensive calculations
const filteredItems = useMemo(() => {
return items.filter(item => item.name.includes(search));
}, [items, search]);
// useCallback for function references
const handleClick = useCallback((id) => {
dispatch({ type: 'SELECT', payload: id });
}, [dispatch]);
// React.memo for component memoization
const ListItem = React.memo(function ListItem({ item, onClick }) {
return <li onClick={() => onClick(item.id)}>{item.name}</li>;
});
Selector Pattern
// Bad: Select entire state
const user = useSelector(state => state);
// Good: Select only needed data
const userName = useSelector(state => state.user.name);
const userAvatar = useSelector(state => state.user.avatar);
// Great: Use memoized selectors
const selectUser = createSelector(
[state => state.user],
user => user
);
const userName = useSelector(state => selectUser(state).name);
Summary
Choose the right state management approach:
- Local State: useState, useReducer
- Shared Component State: Context
- Global App State: Redux, Zustand, Jotai
- Server State: React Query, SWR
- URL State: React Router
Key principles:
- Keep state as local as possible
- Use appropriate tools for the job
- Normalize complex state
- Memoize expensive operations
- Derive state when possible
Build robust state management into your applications!
Comments