Menu

10. State Management

Next.js Master Roadmap - IT Technology

This chapter explores React's built‑in state mechanisms, the Context API for global state, and two popular state‑management libraries—Zustand and Redux Toolkit. You'll learn when to use each approach, how to implement them with practical examples, and how to avoid common pitfalls like prop drilling and unnecessary re‑renders.

No MCQ questions available for this chapter.

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