10. State Management
10.1 React State
React provides several hooks for managing state inside components. The most common are useState for simple values and useReducer for more complex state transitions. Both hooks trigger a re‑render whenever the state changes, ensuring the UI stays in sync with the underlying data.
10.1.1 Local State
Local state is confined to a single component and is ideal for UI toggles, form inputs, or counters that do not need to be shared elsewhere.
- useState – Declares a state variable and its setter.
- Functional updates – Useful when the new state depends on the previous state.
- Lazy initialization – Avoids expensive computations on every render.
Example:
const [count, setCount] = useState(0);
const [form, setForm] = useState({ name: '', email: '' });
// Functional update
setCount(c => c + 1);
// Lazy initialization
const [value, setValue] = useState(() => expensiveInitial());
10.1.2 Shared State
When multiple components need to read or modify the same piece of state, we “lift” the state up to their nearest common ancestor and pass it down via props. This works well for shallow component trees (2‑3 levels) but becomes cumbersome as the depth increases—a problem known as prop drilling.
Example of lifting state:
function Parent() {
const [user, setUser] = useState(null);
return (
<Child user={user} onLogin={setUser} />
);
}
For deeper trees, React offers the Context API or external stores to avoid prop drilling.
10.2 Context API
The Context API provides a way to share values across the component tree without manually passing props at every level. It consists of a createContext call, a Provider component that supplies the value, and a useContext hook (or Context.Consumer) to consume it.
10.2.1 Global State
Typical use cases include theme, authentication, or language preferences—data that many components need but changes infrequently.
Example:
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, login: setUser }}>
{children}
</AuthContext.Provider>
);
}
Consuming the context:
const { user, login } = useContext(AuthContext);
10.2.2 Context Patterns
To keep performance optimal, advanced patterns split contexts, use selectors, or combine Context with reducers.
Split Contexts
Separate high‑frequency values (like theme) from low‑frequency ones (like user) to prevent unrelated components from re‑rendering when only one piece changes.
const ThemeContext = createContext();
const UserContext = createContext();
Selector Pattern
Instead of consuming the whole context object, a selector extracts only the needed slice, causing re‑renders only when that slice changes. Libraries like use-context-selector provide a hook for this, or you can write a custom hook.
// Custom selector hook
function useAuth(selector) {
const auth = useContext(AuthContext);
return selector(auth);
}
// Usage
const userName = useAuth(auth => auth.name);
Reducer + Context
For complex state logic, combine useReducer with Context. The reducer encapsulates state transitions, while the Provider distributes the state and dispatch function.
const [state, dispatch] = useReducer(reducer, initialState);
<MyContext.Provider value={{ state, dispatch }}>
{children}
</MyContext.Provider>
10.3 Zustand
Zustand is a minimalistic, hook‑based state management library with a tiny bundle size (~1 KB). It eliminates the need for Providers and offers a straightforward API for creating stores, middleware, and selectors.
10.3.1 Store Setup
Create a store with create. The function receives a set updater and returns an object containing state and actions.
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 }))
}));
Usage in a component:
const { count, increment } = useStore();
10.3.2 Middleware
Zustand’s middleware system lets you extend stores with features like persistence, devtools, and immutable‑style updates via Immer.
- persist – Saves state to
localStorage(or sessionStorage) and rehydrates on init. - devtools – Integrates with Redux DevTools for time‑travel debugging.
- immer – Allows mutable syntax inside the updater.
Example with persistence and devtools:
import { persist, devtools } from 'zustand/middleware';
const useStore = create(
devtools(
persist((set) => ({
items: [],
addItem: (product) => set(state => ({
items: [...state.items, product]
}))
}), { name: 'shopping-cart' })
)
);
Using Immer middleware for mutable updates:
import immer from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
count: 0,
increment: () => set(state => { state.count++; })
})));
10.4 Redux Toolkit (RTK)
Redux Toolkit is the official, opinionated way to write Redux logic. It reduces boilerplate, provides sensible defaults, and includes powerful utilities like createSlice and createAsyncThunk.
10.4.1 Store
The store is created with configureStore, which automatically sets up Redux DevTools, middleware (including thunk), and combines reducers.
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './authSlice';
export const store = configureStore({
reducer: {
auth: authReducer
}
});
TypeScript helpers:
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
10.4.2 Slice
A slice encapsulates a portion of the Redux state together with its reducers and actions.
import { createSlice } from '@reduxjs/toolkit';
const authSlice = createSlice({
name: 'auth',
initialState: { user: null, token: null },
reducers: {
login: (state, action) => {
state.user = action.payload.user;
state.token = action.payload.token;
},
logout: (state) => {
state.user = null;
state.token = null;
}
}
});
export const { login, logout } = authSlice.actions;
export default authSlice.reducer;
10.4.3 Async Thunks
For side effects such as data fetching, RTK provides createAsyncThunk. It generates three action types (pending, fulfilled, rejected) and integrates seamlessly with extraReducers.
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk(
'auth/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const res = await api.getUser(userId);
return res.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
Handling the fulfilled case inside a slice:
extraReducers: (builder) => {
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.user = action.payload;
});
}
Key Formulas & Decision Matrix
Choosing the right state‑management solution depends on bundle size, boilerplate, learning curve, and the nature of the application.
| Solution | Bundle Size | Boilerplate | Learning Curve | Best For |
|---|---|---|---|---|
useState/useReducer |
0 KB | None | Low | Local component state |
| Context API | 0 KB | Low | Low | Theme, auth, low‑frequency global state |
| Zustand | ~1 KB | Very Low | Low | Medium apps, simple global state |
| Redux Toolkit | ~12 KB | Medium | Medium | Large apps, complex async, team development |
Concrete Examples
Theme Context (Split)
Separating theme from other global state prevents unnecessary re‑renders when the theme toggles.
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{
theme,
toggle: () => setTheme(t => t