Topic #24Core18 min read

Redux (Redux Toolkit)

Redux end to end via Redux Toolkit: the store/action/reducer flow, why unidirectional data flow matters, createSlice with Immer, configureStore and typed hooks, async thunks (createAsyncThunk), memoised selectors (createSelector), and RTK Query — plus when Redux is worth its cost.

#redux#redux-toolkit#state-management#immer#store#createSlice#thunk#selectors#rtk-query

The Redux core idea: one predictable state machine. Redux keeps all shared application state in a single store (one big JavaScript object). You never mutate it directly. Instead you dispatch actions — plain objects describing what happened ({ type: 'cart/itemAdded', payload }) — and pure functions called reducers compute the next state from the current state and the action. Because the only way state changes is 'dispatch an action → reducer returns new state,' the data flow is strictly one-directional and every change is traceable. That predictability is Redux's whole reason for existing.

Why unidirectional flow and immutability matter. Reducers must be pure (no side effects, no mutation) and must return a new state object rather than editing the old one. This immutability is what powers Redux's superpowers: React can cheaply detect changes by reference comparison, the devtools can record every past state and 'time-travel' between them, and bugs are reproducible because state is a deterministic function of the action log. The cost, in classic Redux, was verbose immutable-update code and lots of boilerplate — which is exactly what Redux Toolkit fixes.

Redux Toolkit (RTK) is the official, batteries-included way. Modern Redux is written with RTK; hand-writing action-type constants, action creators, and switch-statement reducers is obsolete. RTK gives you createSlice (generates actions + reducer together), configureStore (sets up the store with good defaults — the thunk middleware, devtools, and dev-time immutability/serializability checks), createAsyncThunk (async flows), createSelector (memoised selectors, re-exported from Reselect), and createApi/RTK Query (full server-state caching). The React docs and Redux docs both recommend RTK as the default.

createSlice: state + reducers + auto-generated actions in one place. A slice owns one region of the store. You give createSlice a name, an initialState, and a reducers object; it returns a reducer plus a matching action creator for every reducer key. So writing an addExpense(state, action) reducer automatically gives you an addExpense(payload) action creator — no separate action-type constant, no boilerplate creator. This colocation (state shape, its transitions, and its actions in one file) is the single biggest ergonomics win over classic Redux.

Immer: write 'mutating' code that is actually immutable. The reducers inside createSlice run through Immer. Immer hands your reducer a draft proxy of the state; you write natural mutations like state.items.push(item) or state.filter = 'all', Immer records those operations against the draft, and then produces a brand-new immutable state from them. So the syntax is ergonomic and mutable-looking, but the store update is fully immutable. The one rule: this only applies inside RTK reducers — never mutate state anywhere else, and do not both mutate the draft and return a value from the same reducer.

configureStore and full TypeScript typing. configureStore({ reducer: { expenses: expenseReducer, cart: cartReducer } }) combines slice reducers into the root store and wires the sensible middleware defaults automatically. From it you derive two types that make everything type-safe: RootState = ReturnType<typeof store.getState> and AppDispatch = typeof store.dispatch. Best practice is to export pre-typed hooksuseAppSelector and useAppDispatch — so every call site is typed without repeating generics, and thunks are dispatchable with correct types.

Reading and writing from components. Components connect via react-redux hooks. useSelector(selectorFn) subscribes the component to a slice of state and re-renders it only when that selected value changes (by reference, using strict equality by default) — this is the fine-grained subscription Context lacks. useDispatch() returns the store's dispatch, which you call with an action creator: dispatch(addExpense(newExpense)). The Provider from react-redux (<Provider store={store}>) wraps the app so the hooks can reach the store — internally that Provider uses React Context.

Async with createAsyncThunk. Reducers are synchronous and pure, so side effects (API calls) live in thunks. createAsyncThunk('users/fetch', async () => …) returns a thunk you dispatch; it automatically dispatches three lifecycle actions — pending, fulfilled, and rejected — which you handle in the slice's extraReducers (via the builder callback) to set loading, data, and error state. This gives async data a clean, standard three-state shape without hand-writing action types for each phase.

Selectors and memoisation with createSelector. A selector is a function that reads a value out of state (state => state.cart.items). Inline selectors are fine for simple reads, but for derived data (a filtered list, a computed total) you want createSelector, which memoises: it recomputes only when its input selectors' results change, and returns the same reference otherwise. That stable reference prevents needless re-renders and avoids recomputing expensive transforms on every dispatch. Colocating selectors in the slice file (selectCartTotal) keeps state access encapsulated so components do not reach into the state shape directly.

RTK Query — server state inside the Redux ecosystem. Rather than hand-writing thunks to fetch and cache API data, RTK Query (createApi) generates fully-typed hooks (useGetUsersQuery, useAddUserMutation) that handle caching, deduping, background refetch, loading/error state, and cache invalidation via tags — the same job TanStack Query does, but integrated into the Redux store and devtools. If you are already on Redux, RTK Query is the recommended way to handle server state so you do not confuse it with client state.

When Redux is worth its cost — and when it is not. Redux shines for large apps with complex, frequently-updated, cross-cutting client state shared by many components, big teams that benefit from a strict, auditable, one-way data flow, and cases where the devtools' time-travel and action log are genuinely useful. It is overkill for small apps, for local UI state (use useState), for a couple of siblings (lift state), or for a stable global value (Context). And server data belongs in RTK Query/TanStack Query, not in hand-managed slices. Reach for Redux when the predictability and tooling pay for the ceremony — RTK has made that ceremony small, but it is not zero.

The mental model (memorise this). Redux is a single immutable state machine: components dispatch actions (facts about what happened), pure reducers compute the next state, and components subscribe to slices via selectors that re-render only on change. Redux Toolkit makes this ergonomic — createSlice colocates state and auto-generates actions, Immer lets you write mutating-looking reducers that stay immutable, configureStore wires it up and types it, thunks handle async, createSelector memoises derived data, and RTK Query owns server state.

Backend Analogy

Redux is event sourcing for the frontend. Dispatched actions are immutable domain events appended to a log, reducers are the event handlers that fold each event into the aggregate's next state, and the store is the current materialised aggregate — pure, deterministic, and replayable, which is literally what the devtools' time-travel does (replay the event log). createSlice is like a CQRS command handler generated from a single definition: declaring the reducer gives you the matching command (action creator) for free, the way a Spring @CommandHandler binds a command to its state transition. Immer is a persistent-data-structure trick: you appear to mutate a draft, but it emits a new immutable version, like a copy-on-write value object. createAsyncThunk's pending/fulfilled/rejected trio mirrors a saga's lifecycle events, createSelector is a memoised read-model projection (a cached query view), and RTK Query is your caching repository layer with tag-based cache eviction — the frontend equivalent of a second-level cache with fine-grained invalidation.

Key Insights
  • Redux = one immutable store changed only by dispatching actions into pure reducers; the strictly one-way flow makes every state change traceable.
  • Reducers must be pure and return new state; immutability is what enables reference-based change detection and devtools time-travel.
  • Redux Toolkit is the official default — hand-writing action-type constants, creators, and switch reducers is obsolete.
  • createSlice colocates state + reducers and auto-generates a matching action creator for every reducer key.
  • RTK reducers run through Immer, so mutating-looking code (state.items.push(...)) produces an immutable update — but only inside those reducers.
  • configureStore wires middleware defaults and lets you derive RootState and AppDispatch; export pre-typed useAppSelector/useAppDispatch hooks.
  • useSelector subscribes to a slice and re-renders only when that value changes — the fine-grained subscription Context lacks; useDispatch sends actions.
  • createAsyncThunk handles side effects by dispatching pending/fulfilled/rejected, handled in extraReducers for a standard loading/data/error shape.
  • createSelector memoises derived data, returning a stable reference so components do not re-render or recompute needlessly.
  • RTK Query handles server state (caching, refetch, tag-based invalidation) inside the Redux store; keep server state out of hand-written slices.

Worked Code

createSlice — state, reducers, and auto-generated actions (Immer-powered)
TypeScript
// store/expenseSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface Expense { id: number; category: string; amount: number; }
interface ExpenseState { items: Expense[]; filter: string; }

const initialState: ExpenseState = { items: [], filter: 'all' };

const expenseSlice = createSlice({
  name: 'expenses',
  initialState,
  reducers: {
    // Each key becomes BOTH a case reducer AND an action creator.
    addExpense(state, action: PayloadAction<Expense>) {
      state.items.push(action.payload); // Immer: mutation-looking, still immutable
    },
    removeExpense(state, action: PayloadAction<number>) {
      state.items = state.items.filter((e) => e.id !== action.payload);
    },
    setFilter(state, action: PayloadAction<string>) {
      state.filter = action.payload;
    },
  },
});

// Action creators are generated for you — no action-type constants.
export const { addExpense, removeExpense, setFilter } = expenseSlice.actions;
export default expenseSlice.reducer;
configureStore + typed hooks + memoised selector
TypeScript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import expenseReducer from './expenseSlice';

export const store = configureStore({
  reducer: { expenses: expenseReducer },
  // configureStore already adds thunk middleware, devtools, and dev checks.
});

// Derive types from the store, then export PRE-TYPED hooks.
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

// Memoised, colocated selector for DERIVED data. Recomputes only when
// items or filter change, and returns a stable reference otherwise.
export const selectVisibleExpenses = createSelector(
  [(s: RootState) => s.expenses.items, (s: RootState) => s.expenses.filter],
  (items, filter) =>
    filter === 'all' ? items : items.filter((e) => e.category === filter),
);
createAsyncThunk — async side effects with pending/fulfilled/rejected
TypeScript
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

interface User { id: number; name: string; }
interface UsersState { list: User[]; status: 'idle' | 'loading' | 'failed'; }

const initialState: UsersState = { list: [], status: 'idle' };

// Reducers are pure/sync, so the API call lives in a thunk.
export const fetchUsers = createAsyncThunk('users/fetch', async () => {
  const res = await fetch('/api/users');
  return (await res.json()) as User[];
});

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  // Handle the three auto-dispatched lifecycle actions.
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => { state.status = 'loading'; })
      .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
        state.status = 'idle';
        state.list = action.payload;
      })
      .addCase(fetchUsers.rejected, (state) => { state.status = 'failed'; });
  },
});

export default usersSlice.reducer;
Wiring the Provider and consuming in a component
TSX
import { Provider } from 'react-redux';
import { store, useAppSelector, useAppDispatch, selectVisibleExpenses } from './store';
import { addExpense, removeExpense } from './store/expenseSlice';

// react-redux's <Provider> makes the store reachable by the hooks
// (internally it uses React Context to pass the store down).
export function App() {
  return (
    <Provider store={store}>
      <ExpenseList />
    </Provider>
  );
}

function ExpenseList() {
  // useSelector re-renders this component ONLY when the selected value changes.
  const items = useAppSelector(selectVisibleExpenses);
  const dispatch = useAppDispatch();

  return (
    <div>
      <button onClick={() => dispatch(addExpense({ id: Date.now(), category: 'food', amount: 12 }))}>
        Add
      </button>
      {items.map((e) => (
        <div key={e.id}>
          {e.category}: ${e.amount}
          <button onClick={() => dispatch(removeExpense(e.id))}>Delete</button>
        </div>
      ))}
    </div>
  );
}
RTK Query — server state with tag-based cache invalidation
TypeScript
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

interface User { id: number; name: string; }

// createApi generates fully-typed hooks and manages the cache for you.
export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => 'users',
      providesTags: ['User'], // this cache entry is tagged 'User'
    }),
    addUser: builder.mutation<User, string>({
      query: (name) => ({ url: 'users', method: 'POST', body: { name } }),
      invalidatesTags: ['User'], // after adding, refetch anything tagged 'User'
    }),
  }),
});

// Auto-generated hooks: loading/error/caching handled for you.
export const { useGetUsersQuery, useAddUserMutation } = api;

Interview-Ready Q&A

All shared state lives in one store. A component dispatches an action — a plain object describing what happened, like { type: 'cart/itemAdded', payload }. A pure reducer receives the current state and that action and returns a new state object, never mutating the old one. The store notifies subscribers, and components reading the changed slice via useSelector re-render. Because the only way to change state is dispatch → reducer → new state, the flow is strictly one-directional and every change is traceable, which is what enables devtools time-travel.

Things to Remember
  • 1One store, changed only by dispatching actions into pure reducers; strictly one-way, fully traceable flow.
  • 2Reducers stay pure and return new state; immutability powers change detection and time-travel.
  • 3Redux Toolkit is the default; classic action-type/creator/switch boilerplate is obsolete.
  • 4createSlice = name + initialState + reducers, and auto-generates a matching action creator per reducer.
  • 5Immer lets reducers use mutating syntax while producing immutable updates — only inside RTK reducers.
  • 6configureStore wires middleware and types; export pre-typed useAppSelector and useAppDispatch.
  • 7useSelector gives fine-grained, per-slice subscriptions (unlike Context); useDispatch sends actions.
  • 8createAsyncThunk = async side effects via pending/fulfilled/rejected handled in extraReducers.
  • 9createSelector memoises derived data and returns a stable reference to avoid needless recompute/re-render.
  • 10RTK Query owns server state (caching, refetch, tag invalidation); keep it out of hand-written slices.

References & Further Reading