Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~29 min· topic 4 of 6

Topic 15.4

Redux with Redux Toolkit

In one line

Redux keeps app state in one store that changes only when you dispatch an action, which a pure reducer turns into the next state; Redux Toolkit is the official, modern way to write it, with slices, Immer, thunks, memoised selectors and RTK Query.

Think of it like this

A bank. You can't walk into the vault and change your balance yourself. You fill in a slip ("deposit ₹500 to account 42"), hand it to the cashier, and the cashier follows fixed rules to write the new balance into the ledger. Every slip is kept, so anyone can replay the day and see exactly how each balance came to be. In Redux, the slip is an action, the cashier's rules are the reducer, and the ledger is the store.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Store
The one object that holds Redux state. You read it with selectors and change it only by dispatching actions.
Action
A plain object describing what happened: { type: "todos/added", payload: "Buy milk" }.
Dispatch
Sending an action to the store so the reducers can work out the next state.
Reducer
A pure function (state, action) => newState that never changes the old state.
Slice
One feature's part of the state plus its reducers and generated actions, made by createSlice.
Immer
A library that lets you write mutating code on a draft and produces a new immutable state from it.
Thunk
A function you dispatch that can do async work and dispatch other actions.
Middleware
Code between dispatch and the reducers that sees every action, such as a logger.
Memoised selector
A selector that remembers its last inputs and result, and returns the same result while inputs are unchanged.
RTK Query
Redux Toolkit's built-in data fetching and caching tool, which generates hooks per endpoint.

Step by step

01Create a slice

Install with npm install @reduxjs/toolkit react-redux. Each feature gets a slice file with its initial state and reducers. Export the generated actions and the reducer.

src/features/cart/cartSlice.jswhole filejsx
import { createSlice } from "@reduxjs/toolkit";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    itemAdded(state, action) {
      state.items.push(action.payload); // Immer makes this safe
    },
    itemRemoved(state, action) {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },
  },
});

export const { itemAdded, itemRemoved } = cartSlice.actions;
export default cartSlice.reducer;

02Configure the store and provide it

configureStore combines slice reducers under keys. The keys become the top level of the state: state.cart.items.

src/store.jswhole filejsx
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./features/cart/cartSlice";

export const store = configureStore({ reducer: { cart: cartReducer } });

// main.jsx
// <Provider store={store}><App /></Provider>

03Read with useSelector, change with dispatch

Components select the smallest value they need and dispatch actions made by the action creators.

CartButton.jsxwhole filejsx
function CartButton({ product }) {
  const count = useSelector((state) => state.cart.items.length);
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(itemAdded(product))}>Add ({count})</button>;
}

04Follow one action through Redux

Every change takes the same path, which is why Redux apps are easy to debug: the DevTools show each action and the state before and after it.

Follow one action through Reduxdiagram
Rendering diagram…

05Add async with a thunk

createAsyncThunk wraps an async function and dispatches pending, fulfilled and rejected actions. The slice reacts in extraReducers. For plain server data, prefer RTK Query below.

06Type the hooks (TypeScript)

Derive the types from the store once and export pre-typed hooks, so every component gets correct types without repeating them. withTypes exists since react-redux 9.1.

src/hooks.tswhole filetsx
import { useDispatch, useSelector } from "react-redux";
import type { store } from "./store";

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();

Try it yourself

  1. 1

    Return and mutate together

    In the first example, change added to state.items.push(...); return state.items;. Predict what happens. Run it: Immer throws [Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft. Pick one style per reducer.

  2. 2

    Remove the memoisation

    In the createSelector example, replace useSelector(selectAffordable) with useSelector((s) => s.products.items.filter((p) => p.price <= s.products.maxPrice)). Predict the logs for Unrelated change. Run it: a development warning about the selector returning a different result, and Affordable renders on the unrelated click.

  3. 3

    Unwrap a failed thunk

    In the thunk example, change the last lines to try { await store.dispatch(fetchUser(2)).unwrap(); } catch (e) { console.log("caught:", e.message); }. unwrap() turns the rejected action back into a thrown error, which is handy in a form submit handler.

Code & diagrams

A slice, Immer, useSelector and dispatch New tab

`added(...)` only **builds** an action object; nothing changes until it is dispatched. After the dispatch, the old state still has 1 item: Immer produced a new state instead of changing the old one. Click a todo to toggle it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

{ type: 'todos/added', payload: 'Write a reducer' }
old state untouched: 1 | new: 2
  render TodoList
  render Remaining: 2
click first todo
  render TodoList
  render Remaining: 1
createAsyncThunk and a logging middleware New tab

The thunk itself never reaches the reducers; the middleware only logs the plain actions it dispatches. `await dispatch(thunk)` resolves with the final action (it doesn't throw on failure; call `.unwrap()` on the result if you want a thrown error).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  render Profile: idle
action: user/fetch/pending
  render Profile: loading
action: user/fetch/fulfilled
  render Profile: succeeded
action: user/fetch/pending
  render Profile: loading
action: user/fetch/rejected
  render Profile: failed
thunk result type: user/fetch/rejected
createSelector: derived data without extra renders New tab

On the unrelated change, `items` and `maxPrice` are the same references (Immer kept them), so `createSelector` returns the cached array: no filtering, no render. Changing `maxPrice` recomputes once and renders once.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

  filtering, run 1
  render Affordable: Pen, Mug
click Unrelated change
click Max ₹1000
  filtering, run 2
  render Affordable: Pen, Bag, Mug
RTK Query in brief: an API slice with generated hookstsx
// src/services/postsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

type Post = { id: number; title: string };

export const postsApi = createApi({
  reducerPath: "postsApi",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Post"],
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({
      query: () => "posts",
      providesTags: ["Post"],
    }),
    addPost: build.mutation<Post, { title: string }>({
      query: (body) => ({ url: "posts", method: "POST", body }),
      invalidatesTags: ["Post"],          // refetches getPosts after a successful add
    }),
  }),
});

export const { useGetPostsQuery, useAddPostMutation } = postsApi;

// store.ts: add the API's reducer and middleware
// configureStore({
//   reducer: { [postsApi.reducerPath]: postsApi.reducer, cart: cartReducer },
//   middleware: (getDefault) => getDefault().concat(postsApi.middleware),
// });

// In a component:
// const { data: posts, isLoading, error } = useGetPostsQuery();
// const [addPost, { isLoading: saving }] = useAddPostMutation();

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A selector that returns a new object

Write const view = useSelector((s) => ({ count: s.todos.items.length })); in a component.

terminal
$ Console (development)
── what you'll see ──
Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization { state: { ... }, selected: { count: 0 }, selected2: { count: 0 }, stack: '...' }

Break #2

Mutating state outside a slice reducer

In a component, write const items = useSelector((s) => s.todos.items); items.push(newTodo);.

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Cannot add property 1, object is not extensible

Myth vs fact

Myth

Redux is dead.

Fact

Redux Toolkit is actively maintained and widely used in large apps. What faded is hand-written Redux boilerplate and the habit of putting server data in Redux by hand; RTK and RTK Query replaced both.

Myth

Redux means huge amounts of boilerplate.

Fact

That was pre-2019 Redux. With RTK a feature is one createSlice call; action types and creators are generated.

Myth

Immer makes Redux state mutable.

Fact

You write mutating code on a draft, but every reducer still produces a new, frozen, immutable state. Old states stay untouched, which is what makes time-travel debugging work.

Myth

Everything in the app should go in the Redux store.

Fact

The Redux docs themselves recommend keeping form state and UI state local unless it is truly global. Server data belongs in RTK Query or another cache.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    RTK 2.0 (December 2023) removed the object syntax for extraReducers and createReducer (use the builder callback), made configureStore's middleware option a callback only, and moved to Reselect 5 and Immer 10. Version 2 also added combineSlices and lazy-loaded slices (inject) for code-split apps.

  • ▸

    useSelector runs every subscribed selector after every dispatch. In an app with thousands of subscribed rows, selector cost adds up: keep selectors cheap, memoise derived data, and use normalised state with createEntityAdapter (ids array plus entities by id), which makes "select one item by id" O(1) and updates touch only one entry.

  • ▸

    Side effects that react to actions ("when a todo is added, save a draft; when the user logs out, clear caches") fit createListenerMiddleware: startListening({ actionCreator: added, effect: async (action, api) => { ... } }). It's RTK's lighter alternative to redux-saga and redux-observable.

  • ▸

    The development checks (immutableCheck, serializableCheck) warn when you store non-serialisable values like Date, Map, class instances or promises. Store ISO strings and plain objects instead, so DevTools, persistence and time travel keep working.

Remember this

  1. 1

    Redux is a pattern and a library for global client state, popular since 2015 and still widely used in large apps. Three rules define it. One store holds the app's global state as a plain object tree. The state is read-only: the only way to change it is to dispatch an action, a plain object describing what happened, like { type: "todos/added", payload: "Buy milk" }. Changes are made by reducers: pure functions (state, action) => newState that return a new state without mutating the old one, exactly like useReducer (Topic 9.4), but for the whole app.

  2. 2

    Redux Toolkit (RTK, package @reduxjs/toolkit) is the official way to write Redux today; hand-written Redux with switch statements and action-type constants is legacy (the old createStore is marked deprecated). **configureStore creates the store with good defaults: the thunk middleware, development checks that catch mutations and non-serialisable values, and Redux DevTools support. createSlice** takes a name, an initialState and a reducers object and generates the reducer and an action creator for each case: a slice named todos with a reducer added gives you todosSlice.actions.added(payload), which builds { type: "todos/added", payload }.

  3. 3

    Slice reducers look like they mutate (state.items.push(todo)), but they don't. RTK runs them through Immer: your code edits a draft, a stand-in proxy object that records every change, and Immer builds a brand-new immutable state from those changes, sharing every untouched part with the old state (structural sharing). Two rules: either mutate the draft or return a new value, never both; and don't mutate outside a slice reducer (in components the state is frozen in development, so mutation throws).

  4. 4

    react-redux connects the store to React. Wrap the app in <Provider store={store}>. Read with **useSelector(selector)**: after every dispatched action, react-redux runs each component's selector and re-renders the component only if the result changed by === (reference). Change state with **dispatch(action) from useDispatch()**. Selectors that compute new arrays or objects (state.items.filter(...)) return a new reference every time, so the component re-renders on every action; in development react-redux warns Selector unknown returned a different result when called with the same parameters. Fix it with **createSelector (from Reselect, re-exported by RTK), which memoises**: it recomputes only when its input selectors' results change, otherwise it returns the cached result.

  5. 5

    Async work goes in thunks: functions dispatched like actions that can wait and dispatch other actions. **createAsyncThunk("user/fetch", async (id) => api.getUser(id))** dispatches user/fetch/pending before the call, then user/fetch/fulfilled with the result as payload, or user/fetch/rejected with action.error if it throws. The slice handles them in **extraReducers** with the builder: builder.addCase(fetchUser.fulfilled, (state, action) => { ... }). Middleware sits between dispatch and the reducers and sees every action (logging, analytics, thunks themselves are middleware).

  6. 6

    RTK Query (part of RTK) is a server-state tool like TanStack Query (Topic 14.6), built into the Redux store. You describe endpoints with **createApi** and fetchBaseQuery, and it generates hooks such as useGetPostsQuery() and useAddPostMutation(), with caching, de-duplication, loading flags and tag-based invalidation (providesTags/invalidatesTags). When Redux fits: large apps with lots of global client state changed from many places, teams that want strict conventions and a full action history (DevTools time travel), complex cross-feature logic (listener middleware, undo), or an existing Redux codebase. For a small app with a theme and a cart, Zustand or context is lighter; for server data, use RTK Query or TanStack Query rather than hand-written thunks.

Explain it without notes

01

Explain Redux's data flow from a click to a re-render.

02

What does Redux Toolkit add over plain Redux, and why can slice reducers "mutate"?

03

How does useSelector decide whether to re-render, and why do you need memoised selectors?

04

What does createAsyncThunk do, and when would you use RTK Query instead?

05

When is Redux a good fit, and when is it overkill?

Practice

01

Write a counterSlice with incremented, decremented and incrementedBy(amount), configure a store, and dispatch three actions from plain JavaScript, logging the state each time.

02

Write a memoised selector selectTotal that returns the cart total from state.cart.items (each item has price and qty).

Trade-offs

  • ↔

    Redux gives one predictable data flow, great DevTools and strong conventions, at the cost of more concepts (actions, reducers, middleware, selectors) and more files than Zustand for the same feature.

  • ↔

    One global store makes cross-feature logic and debugging easy, but every dispatch runs every subscribed selector; very large apps need memoisation and normalised state to stay fast.

Done when you can

  • I can create a slice and export its actions and reducer.

  • I can configure a store and provide it with <Provider>.

  • I read state with useSelector and change it with dispatch.

  • I can write a thunk with createAsyncThunk and handle it in extraReducers.

  • I memoise derived data with createSelector.

  • I can explain what RTK Query does and when Redux fits.