The post in three sentences, in case you need to get back to work: Redux
Toolkit takes the reducer you already know and moves it into a store the full
component tree can read. You write slices, RTK generates the boilerplate,
Immer lets you “mutate” safely, and createAsyncThunk handles the fetches. And
a root-level store is the wrong place for fast-churning data—I have the CPU
graphs to prove it.
Still here? Good. In Part 1 we built the reducer mental model: a pure function taking state and an action, returning new state. That model carries over unchanged—Redux just moves the reducer into a store the rest of the tree can reach. If that first sentence already feels obvious, you’re ready for this one. If it doesn’t, go read Part 1 first; the rest of this post stands on it.
This is the second half of the React Lunch & Learn state-management session,
and it’s where things get opinionated. We’re going to climb the rest of the
ladder: from React’s built-in useReducer, up through plain React-Redux, and
finally to Redux Toolkit (RTK), the library most teams reach for today.
We’ll do it all on a small movie app—a watchlist of favorites, a config slice
that fetches from a movie database API, a bit of sorting and filtering. Realistic
enough to be useful, small enough to fit in your head.
A 60-second recap
For reference, the reducer we built in Part 1—the signature stays the same all the way up the ladder; only the ceremony around it changes:
export const initialState = { query: "", minimumRating: "all", sortBy: "none",} as const;
export function listReducer( state: LocalListState = initialState, action: LocalListAction,): LocalListState { switch (action.type) { case "QUERY_CHANGED": return { ...state, query: action.payload }; case "RATING_CHANGED": return { ...state, minimumRating: action.payload }; case "SORT_CHANGED": return { ...state, sortBy: action.payload }; case "RESET": return initialState; default: return state; }}Those spreads are doing important work—each one returns a brand-new object, and that fresh reference is the signal React uses to re-render. Part 1 covers why immutability is the whole game in a reducer, so I won’t re-litigate it here.
One thing I will re-stress: reducers are a joy to test. They’re pure. Pass in a state, pass in an action, assert on what comes back. No mocking, no DOM, no async. I really enjoy writing unit tests for reducers, which is not a sentence I say about much else.
So if the reducer is the same, what does Redux actually add on top of it?
The one thing Redux actually adds
Strip away the marketing and it comes down to one thing: in Redux, you have to provide a store to React.
import { Provider } from "react-redux";import { store } from "./app/store";
function Root() { return ( <Provider store={store}> <App /> </Provider> );}If you’ve used React Context, this should look familiar—because a Provider is basically Context under the hood. A provider at the top of the tree, a value (here, the store) made available to the components underneath. The store is the bulletin board in the office break room: any component can walk over and read it, and any component can post an update—but every update goes through a reducer first, so the board never devolves into chaos. Your app gets the store once, at the root.
A warning before we go any further
That root-level store is a blessing and a curse, so before I sell you on it, let me confess a mistake.
On a past project I had a table that was the beating heart of the app—rows streaming in constantly, each update bumping counts, timestamps, “time since last seen,” status, the works. The first iteration managed all of it through a single global store at the root, and that store updated constantly.
That table was a bad use case for a global store. Updates came in so fast that the browser tab pinned its CPU at 100%—then sailed past it, up to 150%, 175%. That’s one full CPU core maxed out plus three-quarters of a second one, spent entirely on keeping a table current. The UI flashed and fell behind, then caught up in a burst, then fell behind again. And while all that churned, trying to filter or interact with the table took forever to reflect on screen, because each tiny update hit the root subscriber and re-rendered the entire tree beneath it.
So, plainly: Redux is not a silver bullet. High data throughput through a root store is not a great idea. You can make it work if you’re careful; however, I generally stay away because there’s an easier move: bring the data closer to where it’s needed. If one component needs high-frequency updates, don’t broadcast those from the root—keep that state local to that subtree.
With that disclaimer on the record, let’s build the good version.
Building a slice
The first thing you need is a slice. Redux likes to call a piece of global
state a “feature,” so these usually live in a features/ folder. A slice is
what it sounds like—one named wedge of your global state, with its
initial state and the reducers that operate on it.
Let’s start with the favorites slice for our movie app—the simple case, no async:
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";import type { RootState } from "../../app/store";
interface FavoritesState { movieIds: number[];}
const initialState: FavoritesState = { movieIds: [] };
const favoritesSlice = createSlice({ name: "favorites", initialState, reducers: { toggleFavorite(state, action: PayloadAction<number>) { const id = action.payload; if (state.movieIds.includes(id)) { state.movieIds = state.movieIds.filter((movieId) => movieId !== id); } else { state.movieIds.push(id); // looks illegal. it isn't. } }, clearFavorites(state) { state.movieIds = []; }, },});
export const { toggleFavorite, clearFavorites } = favoritesSlice.actions;
export const selectFavoriteIds = (state: RootState) => state.favorites.movieIds;
export default favoritesSlice.reducer;Look at that push. In a hand-written reducer, mutating state in place is a
cardinal sin—you’d hand back the same reference, and the components subscribed
to that state would not notice a change. So what’s going on?
createSlice runs every case reducer through Immer. The state object you
receive isn’t the underlying state—it’s a draft wrapped in proxies. The draft is
tracing paper laid over the original: you scribble whatever you want on the
overlay—push, assign, mutate to your heart’s content—and Immer lifts the sheet
and produces a fresh, correct copy with your changes baked in. The original
never feels the pen.
So you get to write code that reads like a
mutation—state.movieIds.push(id)—while Immer guarantees the immutable update
underneath. React stays happy, and you stop writing spread operators inside
slice reducers. The stance is the same one from Part 1, just enforced for you:
pure reducers, immutable updates—RTK simply makes the “mutating” syntax stay
immutable.
Notice too that we didn’t write an action creator. Because we named the slice
favorites and named the reducer toggleFavorite, RTK generated an action
creator for us, with the type "favorites/toggleFavorite"—it just concatenated
the two. That’s the boilerplate RTK is killing. In old-school Redux you’d
hand-write the action type constant, the action creator, the reducer case, and
the TypeScript types—four places to keep in sync. Change a payload’s shape and
you’re hunting through the codebase for each type: "MY_ACTION" you ever
wrote. RTK collapses that to one declaration.
Async, the Redux way
Now the harder case: our config slice fetches API configuration from a movie database. That’s asynchronous, and a plain reducer cannot do async work. Reducers must be pure and synchronous—no fetches, no promises, no side effects. That’s not an RTK rule; it’s a core Redux rule, and it’s there so your state changes stay predictable and replayable.
So where does the fetch go? Into a thunk—a function that wraps up work to
run later, which is where Redux wants side effects: in middleware like a thunk,
not in a reducer—and RTK gives us
createAsyncThunk for this. (The word “thunk” is fun to say. It kind of sounds like
flicking something.)
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";import type { RootState } from "../../app/store";
export const fetchConfig = createAsyncThunk( "config/fetchConfig", async (_, { rejectWithValue }) => { const response = await fetch("https://api.themoviedb.org/3/configuration"); if (!response.ok) { return rejectWithValue("Failed to load movie database config"); } return response.json(); },);
interface ConfigState { status: "idle" | "loading" | "succeeded" | "failed"; error: string | null; config: Record<string, unknown> | null;}
const initialState: ConfigState = { status: "idle", error: null, config: null };
const configSlice = createSlice({ name: "config", initialState, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchConfig.pending, (state) => { state.status = "loading"; }) .addCase(fetchConfig.fulfilled, (state, action) => { state.status = "succeeded"; state.config = action.payload; }) .addCase(fetchConfig.rejected, (state, action) => { state.status = "failed"; state.error = (action.payload as string) ?? "Unknown error"; }); },});
export const selectConfig = (state: RootState) => state.config;
export default configSlice.reducer;Now for the part to pause on. A thunk created this way generates three
actions: pending, fulfilled, and rejected. If you’re nodding along
thinking “those are the three states of a Promise”—exactly right, and that’s
the trick. createAsyncThunk maps the lifecycle of a promise onto three
actions, and now I can attach a state change to each one: loading on pending,
the data on fulfilled, an error message on rejected. That rejectWithValue
helper lets me control what ends up in action.payload on the rejected case
instead of a default serialized error.
Async thunks handle more than fetch, by the way. Anything async is a
candidate—for example, writing to IndexedDB, file-system work in a desktop-style app, any
task that returns a promise.
You’ll have noticed this slice used a field the favorites slice
didn’t—extraReducers—so let’s draw the distinction.
reducers vs. extraReducers
reducersis for actions this slice owns. RTK generates an action creator and action type for each one. This is wheretoggleFavoritelives.extraReducersis for responding to actions defined elsewhere—for example, thepending/fulfilled/rejectedactions thatcreateAsyncThunkcreated. RTK does not generate new action creators for these; you’re wiring up reactions to actions that already exist.
extraReducers uses a builder object, and the builder can do things a plain
switch can’t. Beyond builder.addCase(action, reducer) for a specific action,
you get:
builder.addMatcher(predicate, reducer)—match a range of actions with a predicate. RTK ships matchers likeisPending,isFulfilled, andisRejected, and you can combine them. This is how you do something like “if any of my API thunks is in flight, flip the app-wide status to loading”—one matcher instead of N cases.builder.addDefaultCase(reducer)—your fallback, thedefault:of the builder world.
Both reducers and extraReducers case functions are wrapped in Immer, so the
“mutating” syntax works in both. Use reducers for actions your slice owns;
reach for extraReducers when the action comes from somewhere else—an async
thunk, another slice—or when you need that matcher control.
Wiring it up
Two slices written, but they haven’t been assembled into a store yet. That’s
configureStore’s job:
import { configureStore } from "@reduxjs/toolkit";import favoritesReducer from "../features/favorites/favoritesSlice";import configReducer from "../features/config/configSlice";
export const store = configureStore({ reducer: { favorites: favoritesReducer, config: configReducer, },});
export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;You pass configureStore an object mapping slice names to their reducers, and
it combines them into a root reducer for you. configureStore also wires up the
Redux DevTools and sensible default middleware—including the thunk middleware
our async thunk needs, plus development-only checks that yell at you if you
accidentally mutate state outside of Immer. Then you hand store to the
<Provider> from earlier, and any component in the app will be able to read it.
It’s almost annoyingly little code.
Consuming the state
In components, you read state with useSelector and fire actions with
useDispatch. In a TypeScript app you’d typically wrap these as typed hooks
(useAppSelector, useAppDispatch) so you don’t re-annotate RootState
everywhere:
function App() { const dispatch = useAppDispatch(); const config = useAppSelector(selectConfig); const favoriteIds = useAppSelector(selectFavoriteIds);
useEffect(() => { if (config.status === "idle") { dispatch(fetchConfig()); } }, [config.status, dispatch]);
// ...}Read that useEffect. “While the status is idle, dispatch fetchConfig.” I
know what that’s doing at a glance—the readability is the point.
And the type inference does a lot of work behind the scenes. Because toggleFavorite was
declared with PayloadAction<number>, the dispatch site knows it wants a
number. The selectors carry their return types through. You didn’t write much
type ceremony; however, you’re pretty well protected against passing the wrong
thing.
And the favorites counter in the header—the one mentioned at the end of Part
1—falls out for free. A Header component anywhere in the tree calls
useAppSelector(selectFavoriteIds) and renders the array’s length. No prop
threading, no lifting state up. The store is the break-room bulletin board, and
the header just walks over and reads it.
Compare the consumer side to the useReducer world from Part 1: there you call
const [state, dispatch] = useReducer(...) and dispatch is local to that
component. Here, useDispatch reaches into the global store. Slightly different
API, same fundamental move—dispatch an action, a reducer produces new state, the
UI updates.
The re-render trap
State management and re-rendering are joined at the hip, so one trap before we climb to the summary.
In our movie app, App passes an onToggleFavorite callback down through
MovieList—which doesn’t use it—and into MovieCard, which finally calls it.
That’s prop drilling, and one layer of it is perfectly
fine—Part 1 makes the full case for why
drilling got over-vilified.
The trap is elsewhere. If onToggleFavorite is not memoized, it’s a
brand-new function reference on each render of App. Pass that fresh reference
down as a prop and you’ll see even a memoized MovieList re-render, purely
because the reference changed—even though none of the props it cares about did. Now you’re
re-rendering a component, plus the child that depends on it, and if you keep
going down the tree, more and more.
For a prototype movie app, who cares—it’s a workshop toy. But picture this in a production app with a deep tree and frequent updates (remember the 175%-CPU table): hand a component a dependency it doesn’t even use, let that reference churn, and you’ve signed up an entire subtree to re-render for no reason.
That churn is the pain the
React Compiler is built to remove. The
compiler automatically memoizes—hoisting your callbacks and values into the
equivalent of useMemo/useCallback for you, so the reference stays stable
across renders
without you hand-writing the wrapper. (Worth a correction to something I said
live: this is no longer a React-19-only future. The React Compiler reached v1.0
in late 2025 and officially supports React 17 and up—versions older than 19 just
need the react-compiler-runtime package. So depending on your version, this
optimization may already be available to you.) Until you’ve adopted it, though,
you do need to think about reference stability wherever a memoized child is on
the receiving end—useCallback that handler before you drill it.
The three tiers, and when to reach for each
So, the ladder, smallest to largest footprint. Like a physical ladder, you climb one rung at a time—and you stop climbing the moment you can reach what you need.
useReducer—React’s built-in. The same reducer mental model, lighter and leaner, and it needs no store and no library at all. Zero dependencies. Great for local-ish complex state—for example, a multi-field form, a wizard, or a dark-mode toggle.- React-Redux (plain Redux)—has a store, but it’s the tough-love library: you hand-write your actions, action types, reducers (with manual spreading to break references—and if your state is multi-level, you spread at every level), and types. It builds character. It’s also a lot of code each time you add a piece of state.
- Redux Toolkit (RTK)—depends on Redux and wraps it with helpers that delete
that boilerplate:
createSlice, Immer-powered reducers,createAsyncThunk, matchers, generated action creators. It’s the smaller surface to write against even though it sits on top of the bigger library.
My rule of thumb:
- Reach for
useReducerfirst—it’s zero-dependency, and you may never need more. - When you outgrow it through complexity, skip plain React-Redux and go
straight to RTK. If
useReducerwas already too complicated for your state, I think plain Redux will be just as painful—and if you’re refactoring anyway, you might as well refactor into Toolkit. - Watch the throughput. None of these are a license to pump high-frequency data through a root store. When that’s the need, move the state closer to where it’s used.
One more practice that pays off regardless of tier: when a reducer grows into a
block of logic you don’t even want to read, extract it into a well-named
utility function. state.movies = buildMovieList(state, action.payload) tells
you what’s happening; thirty lines of inline merging does not. Smaller pieces,
lower cognitive load—and it keeps your reducers pure and easy to test, which is
where we started.
Conclusion
Across both parts, the throughline is a single idea wearing different costumes.
A reducer—(state, action) => newState—is the same animal whether it’s local in
useReducer, hand-cranked in plain Redux, or generated for you by an RTK slice.
What changes as you climb the ladder is how much ceremony you write and where
your state lives, not the underlying model.
Start small, stay honest about complexity, and remember that the most expensive
state-management decision you can make is putting fast-churning data somewhere
it has to ripple through your whole tree—that costs more than any library
choice. Reach for
useReducer first, graduate to RTK when complexity earns it, keep your reducers
pure and your logic extracted, and let the React Compiler handle the memoization
you used to do by hand.
That wraps the state-management arc of the series. See you in the next workshop.
Related in this series
- State Management in React, Part 1—the
predecessor to this post; start here for the
useState→useReducerfoundation - Designing Good Custom React Hooks
- Differences Between useMemo and useCallback
- Intro to React and File Organization—where the series began