Redux Toolkit Awareness: Store, Slices, Thunks, and RTK Query (ECOSYSTEM, optional)
Get oriented in Redux Toolkit's core vocabulary and mental model — store, slice, reducer, action, dispatch, selector, middleware, async thunks, RTK Query, and the immutable-update-via-Immer model — enough to read and reason about a Redux Toolkit codebase, without treating it as something every React app needs.
Redux Toolkit (often written RTK) is the official, modern way to write Redux — it exists specifically to fix how verbose and boilerplate-heavy classic hand-written Redux used to be. This lesson is marked ECOSYSTEM and optional: Redux Toolkit is a third-party library, not part of React itself, and plenty of production apps never need it (see the State Management Ecosystem lesson for when a global state library is actually warranted). The goal here is awareness — you should be able to read a Redux Toolkit codebase and understand what each piece does, even if you choose lighter tools for your own projects.
Store, slice, and reducer
The store is Redux's single, centralized object holding an entire app's client state — instead of state scattered across many components, all Redux-managed state lives in one place, and every component that needs it reads from that one store. A store is created once, at the top of the app, and provided to the whole component tree via a <Provider store={store}> wrapper.
A slice is Redux Toolkit's unit of organization for one piece of that store: createSlice takes a name, an initial state, and a set of 'reducer' functions describing how that slice's state changes, and automatically generates the corresponding action creators and action types for you. Before Redux Toolkit, a developer hand-wrote action type constants, action creator functions, and a switch-statement reducer separately for every feature; createSlice collapses all three into one call.
A reducer, in both classic Redux and Redux Toolkit, is a pure function that takes the current state and an action and returns the next state — it must never mutate its input directly and must always be deterministic (same state and action in, same new state out, no side effects like API calls inside it). Redux Toolkit's createSlice reducers look like they mutate state directly (state.items.push(newItem)), but this is safe specifically because of the Immer-based immutable update model covered later in this lesson.
Actions, dispatch, and selectors
An action is a plain, serializable object describing something that happened — { type: 'cart/itemAdded', payload: { id: '1', qty: 2 } } — and it is the only way to change the Redux store's state; components never write to the store directly. dispatch(action) is the function that sends an action to the store, which runs it through the appropriate reducer(s) to compute the new state and notifies every subscribed component so it can re-render if the part of state it reads changed.
A selector is a function that reads a specific piece (or derived value) out of the store — const items = useSelector((state) => state.cart.items) — and, exactly as in the broader state-management ecosystem, writing selectors that return the smallest useful slice of data is what keeps components from re-rendering on every unrelated store change. Redux Toolkit's createSlice.selectors and the standalone reselect library both exist to make memoized, reusable selectors easier to write for expensive derived computations.
The overall data flow — a component dispatches an action, the reducer computes new state, the store notifies subscribers, selectors deliver the relevant slice back to components — is unidirectional and is the defining characteristic of Redux's architecture: state never changes except through this exact action-in, state-out cycle, which is what makes Redux DevTools' time-travel debugging (covered below) possible in the first place.
Middleware, async thunks, and RTK Query
Middleware sits between dispatch and the reducer, able to intercept, log, delay, or transform actions before they reach a reducer — Redux Toolkit's configureStore wires up a sensible default set of middleware automatically, including one that enables async thunks and one that warns you in development if you accidentally mutate state outside a slice's own reducers.
A reducer must be synchronous and side-effect-free, so Redux needs a separate mechanism for asynchronous work like API calls — that mechanism is the async thunk. Redux Toolkit's createAsyncThunk takes an async function (e.g. one that calls fetch) and automatically dispatches pending, fulfilled, and rejected actions around it, which a slice's extraReducers can then handle to update loading and error state without writing that dispatching logic by hand.
RTK Query is Redux Toolkit's built-in answer to server state (see the State Management Ecosystem lesson) — instead of hand-writing thunks and reducers for every API endpoint, createApi lets you declare endpoints once and get auto-generated hooks (useGetInvoicesQuery, useAddInvoiceMutation) with caching, loading/error states, and cache invalidation handled for you, similar in spirit to TanStack Query but wired directly into the Redux store.
Redux DevTools and the immutable update model
Redux DevTools is a browser extension (and matching store configuration, enabled by default in configureStore) that logs every dispatched action alongside a snapshot of the resulting state, and lets you 'time travel' — jump the entire app back to any previous state by clicking an earlier action in the log. This is possible only because of Redux's strict unidirectional, action-driven update model: since state only ever changes in response to a discrete, logged action, replaying or rewinding that log of actions is enough to reconstruct any past (or, by replaying forward, future) state.
Classic Redux required every reducer to return a brand-new state object without mutating the old one (...state, items: [...state.items, newItem]), because React and Redux both rely on comparing old and new references to detect changes efficiently — mutating in place would leave the old and new state as the same object, invisible to that comparison. This is exactly why Redux Toolkit's createSlice reducers can look like they mutate state directly: internally, RTK wraps every reducer with Immer, a library that lets you write mutating-looking code (state.items.push(x)) against a 'draft' object, then produces a real, correctly immutable new state object behind the scenes.
Understanding that Immer is doing the immutability work for you matters practically: it is why state.value = state.value + 1 works fine inside a createSlice reducer, but return state.value + 1 (returning a primitive instead of mutating or returning a new object) does not — and it is why reducer code outside of createSlice (in older, hand-written Redux, or in extraReducers built without Immer's help in some setups) must go back to the explicit spread-and-copy style.
A minimal Redux Toolkit slice, store, and component
createSlice auto-generates both the action creators (cartSlice.actions.itemAdded) and the reducer function — no hand-written switch statement or action type constants needed. configureStore wires up Redux DevTools support and default middleware (including thunk support) automatically.
// features/cart/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type CartState = { items: { id: string; qty: number }[] };
const initialState: CartState = { items: [] };
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
itemAdded: (state, action: PayloadAction<{ id: string; qty: number }>) => {
state.items.push(action.payload); // looks mutating — safe because of Immer
},
itemRemoved: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const { itemAdded, itemRemoved } = cartSlice.actions;
export default cartSlice.reducer;
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";
export const store = configureStore({ reducer: { cart: cartReducer } });
export type RootState = ReturnType<typeof store.getState>;
// A component reading and dispatching
function CartBadge() {
const itemCount = useSelector((state: RootState) => state.cart.items.length);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(itemAdded({ id: "sku-1", qty
…Code Examples
createAsyncThunk turns one async function into three automatically-dispatched action types (pending/fulfilled/rejected); extraReducers listens for those actions on a slice that didn't define them directly, updating loading status and data without any hand-written dispatch calls for each state. Expected result: Dispatching fetchInvoices() sets status to 'loading' immediately, then to 'idle' with items populated on success, or 'failed' if the request throws.
const invoicesSlice = createSlice({ name: "invoices", initialState, reducers: {}, extraReducers ... });
createApi declares endpoints once, and providesTags/invalidatesTags implement cache invalidation declaratively: adding an invoice automatically invalidates the 'Invoice' tag, causing any active getInvoices query to refetch — no manually written thunks, reducers, or dispatch calls for either the fetch or the cache update. This is the same optimistic-update-and-invalidate shape covered in the State Management Ecosystem lesson, but expressed through RTK Query's tag system instead of TanStack Query's queryKey system.
export const fetchInvoices = createAsyncThunk(
"invoices/fetch",
async () => {
const response = await api.get("/invoices");
return response.data; // becomes action.payload of the 'fulfilled' action
}
);
type InvoicesState = { items: Invoice[]; status: "idle" | "loading" | "failed" };
const initialState: InvoicesState = { items: [], status: "idle" };
const invoicesSlice = createSlice({
name: "invoices",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchInvoices.pending, (state) => { state.status = "loading"; })
.addCase(fetchInvoices.fulfilled, (state, action) => {
state.status = "idle";
state.items = action.payload;
})
.addCase(fetchInvoices.rejected, (state) => { state.status = "failed"; });
},
});export const invoicesApi = createApi({
reducerPath: "invoicesApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Invoice"],
endpoints: (builder) => ({
getInvoices: builder.query<Invoice[], void>({
query: () => "/invoices",
providesTags: ["Invoice"],
}),
addInvoice: builder.mutation<Invoice, NewInvoice>({
query: (body) => ({ url: "/invoices", method: "POST", body }),
invalidatesTags: ["Invoice"], // triggers a refetch of getInvoices after a successful add
}),
}),
});
export const { useGetInvoicesQuery, useAddInvoiceMutation } = invoicesApi;
function InvoiceList() {
const { data: invoices, isLoading } = useGetInvoicesQuery();
const [addInvoice] = useAddInvoiceMutation();
if (isLoading) return <Spinner />;
return <ul>{invoices?.map((inv) => <li key={inv.id}>{inv.number}</li>)}</ul>;
}The unidirectional Redux data flow
State only ever changes through this exact cycle — this strict discipline is what makes Redux DevTools' time-travel debugging possible.
Component --dispatch(action)--> Store
|
reducer(state, action)
|
new state
|
Component <--useSelector-- Store (notifies subscribers)Important Rules
A reducer must be a pure, synchronous function — no API calls, no randomness, no direct state mutation outside Redux Toolkit's Immer-wrapped createSlice reducers.
Never write directly to the store from a component — the only way to change state is to dispatch an action.
Use createAsyncThunk (or RTK Query) for any asynchronous work; do not try to make a reducer itself asynchronous.
Write selectors that return the smallest useful slice of state, to avoid re-rendering components on unrelated store changes.
Remember Redux Toolkit is ECOSYSTEM and optional — reach for it when an app has complex, deeply shared client state that benefits from centralized actions and time-travel debugging, not by default.
Where this shows up
Redux Toolkit remains common in large, long-lived enterprise dashboards and admin tools with complex, deeply interconnected client-side state and a need for auditable, replayable state changes (finance, healthcare, and logistics back-office tools are frequent examples), and RTK Query in particular gets adopted by teams already committed to Redux who want a server-state layer that stays inside the same store rather than adding a second, unrelated library like TanStack Query alongside it.
// Reducer outside createSlice mutating state directly — unsafe without Immer's wrapping function cartReducer(state = initialState, action: any) { switch (action.type) { case "cart/itemAdded": state.items.push(action.payload); // BUG: mutates the old state object directly return state; default: return state; } }
// Either use createSlice (Immer handles the mutation-looking code safely)... const cartSlice = createSlice({ name: "cart", initialState, reducers: { itemAdded: (state, action) => { state.items.push(action.payload); }, // safe: Immer produces a new object }, }); // ...or, in hand-written Redux without Immer, copy explicitly: function cartReducer(state = initialState, action: any) { switch (action.type) { case "cart/itemAdded": return { ...state, items: [...state.items, action.payload] }; default: return state; } }
Mutating state directly outside of Redux Toolkit's Immer-wrapped createSlice reducers leaves the old and new state as the exact same object reference, which breaks the reference-equality checks React-Redux and React itself rely on to detect that something changed — components silently fail to re-render.
[multiple-choice] What does createSlice's reducer code like state.items.push(action.payload) actually do under the hood in Redux Toolkit? A. It directly mutates the real Redux store state, which is allowed only in Redux Toolkit B. It mutates a temporary Immer 'draft' object, and Immer produces a correctly immutable new state object behind the scenes C. It has no effect because Redux ignores mutations D. It automatically triggers an API call to persist the change
B. It mutates a temporary Immer 'draft' object, and Immer produces a correctly immutable new state object behind the scenes — Redux Toolkit wraps createSlice reducers with Immer, which lets you write code that looks like direct mutation against a draft object while Immer safely produces a real, new, immutable state object for the actual store.
[multiple-choice] Why can't a Redux reducer directly make an API call to fetch data? A. Reducers must be pure and synchronous; asynchronous side effects like API calls belong in a thunk (such as createAsyncThunk) or RTK Query, not in the reducer itself B. Redux does not support the fetch API C. API calls are only allowed inside React components D. Reducers can only return strings, not promises
A. Reducers must be pure and synchronous; asynchronous side effects like API calls belong in a thunk (such as createAsyncThunk) or RTK Query, not in the reducer itself — A reducer must be a pure, deterministic function with no side effects; asynchronous work is handled separately by mechanisms like createAsyncThunk (which dispatches pending/fulfilled/rejected actions around an async function) or RTK Query.
[multiple-choice] What capability does Redux DevTools' 'time travel' debugging rely on? A. Redux's strict unidirectional flow where state only changes through a logged sequence of dispatched actions, letting the app be replayed to any earlier state B. A built-in database that stores every past version of the app's UI C. React's virtual DOM diffing algorithm D. The browser's built-in undo/redo history
A. Redux's strict unidirectional flow where state only changes through a logged sequence of dispatched actions, letting the app be replayed to any earlier state — Because state can only change through discrete, logged actions run through reducers, replaying or rewinding that action log is enough to reconstruct any past state — a capability that depends entirely on Redux's strict action-driven update model.
Given a createSlice reducer reducers: { statusChanged: (state, action: PayloadAction<'idle' | 'loading'>) => { return action.payload; } } where the slice's initialState is a plain string ('idle'), explain why this particular reducer is safe to write with return instead of a Immer-style mutation, even though other reducers in this lesson mutate state directly.
Immer's draft-mutation tracking only applies to objects and arrays, which can be mutated in place; a slice whose entire state is a primitive (a string, number, or boolean) has nothing to mutate, so the reducer must return the new value directly instead — this is explicitly supported and is the normal way to update primitive-valued state in Redux Toolkit.
Think about what kind of value the slice's state is (an object/array vs. a primitive) and what Immer actually needs to intercept.