A glossy 3D break-room bulletin board split between chaotic red-lit sticky notes and neatly gridded cyan-lit cards, with a glowing funnel passing one note through the middle—Redux Toolkit's store and reducer taming shared state.

State Management in React, Part 2: Redux Toolkit and the Store

Part 6 of 10 in React Lunch & Learn

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:

movieListFiltering.ts
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

  • reducers is for actions this slice owns. RTK generates an action creator and action type for each one. This is where toggleFavorite lives.
  • extraReducers is for responding to actions defined elsewhere—for example, the pending/fulfilled/rejected actions that createAsyncThunk created. 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 like isPending, isFulfilled, and isRejected, 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, the default: 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:

  1. Reach for useReducer first—it’s zero-dependency, and you may never need more.
  2. When you outgrow it through complexity, skip plain React-Redux and go straight to RTK. If useReducer was 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.
  3. 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.