Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Redux & State Management

As your React application grows beyond a few components, you’ll encounter challenges with state management. Props drilling becomes unwieldy, Context API re-renders too much, and tracking state changes becomes a nightmare. Redux addresses these problems with a structured approach to global state. Think of Redux like a bank. Instead of every person (component) keeping cash under their mattress (local state) and passing envelopes of money to each other (props), everyone uses a single bank vault (the store). To deposit or withdraw, you fill out a form (dispatch an action). A teller (the reducer) processes your form and updates the ledger. Anyone can check their balance at any time (subscribe to state). The key insight: there is exactly one ledger, and every change goes through the same teller window, so you always have a clear audit trail.

When Do You Need Redux?

Redux adds complexity, so it’s important to know when it’s worth it:

You Might Need Redux When:

  • Multiple components need access to the same state
  • State updates come from many different sources (user input, API calls, WebSockets)
  • State changes are complex with many interconnected pieces
  • Debugging is difficult because you can’t track what changed and when
  • Team collaboration requires predictable patterns everyone follows

You Probably Don’t Need Redux When:

  • Your app is simple with few components
  • State is localized to individual components
  • Context API handles your needs without performance issues
  • You’re building a prototype or MVP
Start with React’s built-in state management (useState, useContext). Only add Redux when you feel genuine pain from state complexity.

Redux Core Concepts

Before diving into code, understand the philosophy:
ConceptDescriptionAnalogy
StoreA single JavaScript object that holds all app stateThe bank vault
ActionA plain object describing what happened ({ type: 'ADD_ITEM' })A filled-out form submitted to the teller
DispatchThe function you call to send an action to the storeHanding the form to the teller
ReducerA pure function that takes current state + action and returns new stateThe teller processing your form
SelectorA function that reads a specific slice of stateChecking your account balance
Three unbreakable rules:
  1. Single Source of Truth — All app state lives in one store object.
  2. State is Read-Only — The only way to change state is by dispatching actions. You never mutate the store directly.
  3. Pure Reducers — Changes are made with pure functions (given same input, always same output, no side effects).
This predictability makes Redux apps easier to debug, test, and reason about. Every state change has a traceable cause.
┌─────────────────────────────────────────────────────────────┐
│                     Redux Data Flow                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  UI (Component)                                             │
│       │  user clicks "Add to Cart"                          │
│       ▼                                                     │
│  dispatch({ type: 'ADD_ITEM', payload: item })              │
│       │                                                     │
│       ▼                                                     │
│  Reducer: (oldState, action) => newState                    │
│       │                                                     │
│       ▼                                                     │
│  Store updates                                              │
│       │                                                     │
│       ▼                                                     │
│  Subscribed components re-render with new state             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Redux Toolkit (RTK)

We’ll use Redux Toolkit (RTK), the official, recommended way to write Redux logic. It eliminates boilerplate and includes best practices by default.

Installation

npm install @reduxjs/toolkit react-redux

1. Create a Slice

A “slice” contains the reducer logic and actions for a single feature. Think of it like a department in the bank — the counter department handles counting, the users department handles user accounts, etc. Each slice owns its own piece of state and the rules for changing it. features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

export const counterSlice = createSlice({
  // The name is used as a prefix for generated action types:
  // e.g., 'counter/increment', 'counter/decrement'
  name: 'counter',

  // The starting state for this slice
  initialState: {
    value: 0,
  },

  // Each key here becomes both an action creator AND a case in the reducer.
  // Redux Toolkit generates them automatically -- no more switch statements.
  reducers: {
    increment: (state) => {
      // This looks like mutation, but it is safe. Redux Toolkit uses the
      // Immer library under the hood, which intercepts these "mutations"
      // and produces a brand-new immutable state object behind the scenes.
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      // action.payload contains the value passed when dispatching:
      // dispatch(incrementByAmount(5)) --> action.payload === 5
      state.value += action.payload;
    },
  },
});

// RTK auto-generates action creators with the same names as the reducers
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// The reducer function is what we plug into the store
export default counterSlice.reducer;
Common pitfall — “mutating” state outside of RTK: The Immer magic only works inside createSlice and createReducer. If you write a plain reducer without RTK, you must return a new state object the old-fashioned way (return { ...state, value: state.value + 1 }). Mutating state in a plain reducer will cause silent bugs where components do not re-render.

2. Configure the Store

Create the Redux store and add your slices. The store is the single source of truth for your entire application’s state. Think of configureStore as assembling a filing cabinet — each drawer (slice) holds documents for one department, and the cabinet itself is the store. app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
  // Each key here becomes a top-level key in the state tree.
  // state.counter.value comes from the counter slice.
  // Add more slices as your app grows: users, cart, notifications, etc.
  reducer: {
    counter: counterReducer,
  },
  // configureStore automatically adds Redux DevTools integration
  // and the thunk middleware for async logic. You get these for free.
});

3. Provide the Store

Wrap your application with the Provider. main.jsx
import { store } from './app/store';
import { Provider } from 'react-redux';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
);

4. Use State and Dispatch

Use useSelector to read data from the store and useDispatch to send actions. These two hooks are the bridge between your React components and the Redux world. Counter.jsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './features/counter/counterSlice';

export function Counter() {
  // useSelector extracts a value from the store.
  // It subscribes to the store and re-renders this component
  // ONLY when the selected value changes -- not on every store update.
  const count = useSelector((state) => state.counter.value);

  // useDispatch returns the store's dispatch function.
  // Call it with an action creator to trigger a state change.
  const dispatch = useDispatch();

  return (
    <div>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <span>{count}</span>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
}
Performance tip: The selector function you pass to useSelector determines when the component re-renders. Keep selectors focused — select only the data you need. If you select the entire state object (state => state), the component re-renders on every store change.

Async Logic (Thunks)

Redux reducers must be pure and synchronous — they cannot make API calls. So how do you fetch data? Redux Toolkit includes createAsyncThunk, which handles the async work and automatically dispatches pending, fulfilled, and rejected actions for you. Think of it like ordering food: you place the order (dispatch the thunk), the kitchen works on it (async API call), and eventually you either get your meal (fulfilled) or an apology (rejected). The reducer just needs to react to each of those three outcomes.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

// createAsyncThunk generates three action types automatically:
// 'users/fetchById/pending'   -- dispatched when the request starts
// 'users/fetchById/fulfilled' -- dispatched when the request succeeds
// 'users/fetchById/rejected'  -- dispatched when the request fails
export const fetchUser = createAsyncThunk(
  'users/fetchById',
  async (userId, thunkAPI) => {
    const response = await fetch(\`/api/users/\${userId}\`);
    if (!response.ok) throw new Error('Failed to fetch user');
    return response.json();
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: { entities: [], loading: 'idle', error: null },
  // extraReducers handles actions defined outside this slice,
  // including the auto-generated thunk actions.
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = 'loading';
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = 'idle';
        state.entities.push(action.payload);
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = 'idle';
        state.error = action.error.message;
      });
  },
});
Why three states? Tracking pending, fulfilled, and rejected separately lets you show loading spinners, success messages, and error alerts in your UI — all driven by a single dispatched thunk. This is much cleaner than managing isLoading booleans manually.

Common Redux Pitfalls

Pitfall 1 — Storing server state in Redux: Redux is designed for client state (UI toggles, form data, user preferences). For server state (API responses, cached data, pagination), libraries like React Query or SWR are purpose-built and handle caching, refetching, and stale data automatically. Stuffing API data into Redux means you are manually re-implementing what these libraries do out of the box.Pitfall 2 — Selecting too much state: If your useSelector returns a new object every time, the component re-renders on every store update — even if the data it cares about has not changed.
// BAD: creates a new object every render, so React always sees "new" props
const userData = useSelector((state) => ({
  name: state.user.name,
  email: state.user.email,
}));

// GOOD: select primitives individually, or use a memoized selector
const name = useSelector((state) => state.user.name);
const email = useSelector((state) => state.user.email);
Pitfall 3 — Mutating state outside of createSlice: Immer’s safe “mutation” syntax only works inside createSlice reducers and createReducer. If you write a plain reducer or mutate state anywhere else, React will not detect the change and the UI will not update. This is a silent bug that is extremely hard to track down.Pitfall 4 — Putting non-serializable values in the store: Redux expects the store to be serializable (plain objects, arrays, strings, numbers, booleans). Putting class instances, functions, Promises, or Date objects into the store breaks time-travel debugging and persist/rehydrate workflows. Store ISO strings instead of Date objects, and keep functions out of state.

Other Options

While Redux is popular, other libraries exist:
  • Zustand: Minimalist, hook-based state management.
  • Recoil / Jotai: Atomic state management.
  • TanStack Query (React Query): Best for server state (caching API responses).

Redux vs. Context API vs. Other Options

Choosing the right state management tool matters. Here is a practical comparison:
CriteriaContext APIRedux ToolkitZustandReact Query
Best forTheme, auth, localeComplex client stateSimple global stateServer/API state
BoilerplateLowMedium (RTK reduces it)Very lowLow
DevToolsNone built-inExcellent time-travel debuggerBasicExcellent
Re-render controlRe-renders all consumersGranular with selectorsGranularAutomatic
Async supportManualcreateAsyncThunkManualBuilt-in
Learning curveLowMediumLowLow
Practical rule of thumb: Use useState and useContext until you feel pain. If your state logic has many interconnected actions (think: e-commerce cart with coupons, taxes, shipping), Redux gives you structure and debugging tools that pay for themselves. For server state (API data, caching, pagination), skip Redux entirely and use React Query or SWR.

Summary

ConceptDescription
StoreSingle object holding all application state
SliceA feature-sized bundle of reducer + actions (RTK)
ActionA plain object describing what happened
ReducerA pure function that computes the next state
ProviderMakes the store available to the component tree
useSelectorReads (subscribes to) a piece of state
useDispatchSends actions to trigger state updates
createAsyncThunkHandles async operations with auto-generated pending/fulfilled/rejected actions

Next Steps

In the next chapter, you’ll learn about Custom Hooks & Advanced Patterns — extracting reusable logic into hooks you can share across your entire app!

Interview Deep-Dive

Strong Answer: Redux’s value proposition rests on predictability: given the same state and action, a reducer must return the same new state. This enables time-travel debugging (replaying actions produces identical states), hot module reloading, and simple testing.If a reducer made an API call, replaying the action in DevTools would trigger another call — possibly charging a customer twice. If it used Math.random() or Date.now(), replay produces different state. If it mutated existing state instead of returning a new object, the === check for state changes fails and components do not re-render.Redux Toolkit’s configureStore includes middleware that checks for accidental mutations and non-serializable values in development, throwing errors early.Follow-up: Where DO side effects go in Redux?Thunks are async functions that receive dispatch and getState. createAsyncThunk generates pending/fulfilled/rejected actions automatically. Simple and sufficient for most apps. Sagas use generator functions for complex orchestration like racing actions or debouncing. RTK Query auto-generates hooks with caching, deduplication, and optimistic updates for server state. My recommendation: RTK Query for server data, thunks for occasional client async, sagas only for genuinely complex orchestration.
Strong Answer: useSelector subscribes to the store via store.subscribe(). On every dispatch, the subscription callback runs the selector function and compares the result to the previous result using ===. If changed, the component re-renders.Selecting entire state — useSelector(state => state) — returns the root object, which is a new reference on every dispatch. The component re-renders on every store update, defeating the purpose of selectors.Selecting a specific slice — useSelector(state => state.counter.value) — returns a primitive. Unrelated slice changes do not affect this value, so the component skips rendering.The subtle case: useSelector(state => state.items.filter(i => i.active)) creates a new array every call. Use createSelector from Reselect to memoize derived data.Follow-up: How does createSelector differ from useMemo?createSelector is a pure function called outside React’s render cycle — usable in tests, shared across components, composable. useMemo is a hook tied to a component instance. createSelector has a cache size of 1 by default, so parameterized selectors may need a factory pattern or larger cache.
Strong Answer: I would categorize the state and migrate each to the right tool. Server state moves to React Query or RTK Query for caching, deduplication, and background refetching. Form state moves to component-local state or React Hook Form — dispatching an action on every keystroke adds latency for zero benefit. UI state (modal open/closed) moves to local useState.What stays in Redux: truly global client state read by many unrelated components with complex update logic benefiting from DevTools. Authentication, user preferences, shopping cart with pricing rules.The migration approach: start with highest-churn Redux state (forms, API data) first. Each migration reduces dispatches per second, improving performance.Follow-up: How do you manage the transition period with some state in Redux and some in React Query?Run them side by side with both Providers. Migrate one endpoint at a time: replace the thunk, slice, and selector with a useQuery hook. Components use both hooks where needed: useQuery for server data and useSelector for client state. No bridging required. The only complication is Redux middleware orchestrating flows that mix server and client data — restructure those as React hooks composing useQuery with useSelector.