A glossy 3D forklift bathed in red neon light looms over a small couch lit in cyan, illustrating the post's rule of picking the smallest state-management tool that fits instead of reaching for a store.

State Management in React, Part 1: From useState to useReducer

Part 5 of 10 in React Lunch & Learn

When I first started writing React, my answer to “where should this state live?” was usually “wherever it already is, plus a few props.” A component needed a value three levels down, so I drilled it down three levels. Then five. Then a sibling needed it too, so I hoisted it up, and now a component that had no business knowing about a search query was passing it along like a relay baton—except in a relay, the runners at least know why they’re holding the thing. Eventually the drilling got bad enough that someone—maybe me, maybe a teammate—said the magic words: “we should just put this in Redux.”

Sometimes Redux is the right call. Usually it isn’t, and the reason is that we tend to skip a question: what kind of state is this, really? Get that answer right and the tool will pick itself.

The gist, if you only have a minute: classify state before you pick a tool. Most state is private (one component cares), some is related (a few values that move together as one feature), and a little is shared (distant branches of the tree all need it). useState handles the first, useReducer handles the second, and only the third earns a store. The rest of this post is the second bucket, in detail.

This is Part 1 of the React Lunch & Learn series on React state management. Here we’ll set up the problem, draw the lines between the kinds of state, and spend most of our time on useReducer by building the filtering and sorting controls for a small movie-browsing app. Part 2 on Redux Toolkit picks up where local state runs out and Redux Toolkit earns its place.

Three kinds of state

Before you choose a tool, classify the state. I find it useful to think in three buckets.

Private/component state. A value that belongs to a single component—for example, a toggle, an input’s draft text, or whether a menu is open. Private state is useState territory: built-in, local, done.

Related/local state. Several pieces of state that move together as one feature—for example, a search query and a minimum-rating filter and a sort order. They’re still local to one part of the tree, but managing them as three independent useState calls (and the handlers that update them) gets noisy fast. Related state is the case useReducer was built for.

Shared/global state. State that really does need to be read and written from multiple components in different branches of the tree. Picture a deeply nested view where a selection at the top changes what a panel at the bottom renders. When you find yourself traversing many components just to thread a value through, that’s a signal for Context or a store like Redux.

One thing trips people up: useReducer, Redux, and Redux Toolkit all use reducers, so it’s easy to assume “reducer” means “global.” That assumption is wrong. Redux wraps an overarching provider component around your app and passes a store into it—that’s what makes it global. A reducer by itself is just a function. useReducer runs entirely inside one component with no provider and no store—same pattern, completely different scope.

And the word “reducer” is doing less than it sounds like. All it means is funneling a bunch of related state down into one place and reducing how you update it to a single, predictable function—that’s it.

That covers the first two buckets. The third—shared state—is where people panic and over-build, so one more question is in order before we touch any code.

The question to ask before you reach for a store

Before you reach for Context or Redux, ask: what’s the API of this component?

If a parent owns some state and a grandchild needs it, for example, prop-drilling it through one intermediate component is sometimes the right call. Drilling keeps the data flow explicit and the component’s interface obvious. The creators of React have written about combining children into a parent and just colocating the markup there—that’s a legitimate option too. You don’t have to apologize for prop drilling in a code review, and you don’t have to escalate to a global store just because something is two levels deep.

The failure mode I actually see in reviews is “I reached for the heaviest tool by reflex,” not “I drilled a prop.” Moving a couch across the room is a two-person carry; you don’t rent a forklift for it, and if you did, the forklift would take longer to deliver than the couch took to move. Same deal here: pick the smallest tool that fits the shape of the state. With that framing, let’s build something.

The movie app: what we’re building

The demo is a movie-browsing app. There’s a list of movie cards, each with a title and a rating, and a row of controls above it: a search box, a minimum-rating filter, and a sort dropdown. Type in the search box and you’ll see the list filter live. Set a minimum rating and low-rated movies will drop out. Sort by title or rating and the order changes. Hit reset and the controls snap back to their defaults.

None of that needs a backend, and none of it needs global state. The query, the minimum rating, and the sort order are all related local state belonging to the movie list, which is pretty much the textbook case for useReducer.

From scattered useState to a single reducer

Let’s start with the naive version, where the movie list grows a useState per control:

function MovieList({ movies }: { movies: Movie[] }) {
const [query, setQuery] = useState("");
const [minimumRating, setMinimumRating] = useState<Rating>("all");
const [sortBy, setSortBy] = useState<SortBy>("none");
// ...and a handler, or an inline setter, for every single one
}

It works. However, the related pieces are scattered, the “reset” action has to remember to call three setters, and each new filter adds another useState plus another handler. The state is one concept—how the list is filtered and sorted—fragmented across three variables.

A reducer pulls that concept back together. Instead of three setters, one dispatch. Instead of three values, one state object:

const [localState, dispatchLocal] = useReducer(listReducer, initialState);

That destructuring should look familiar: state on the left, the updater on the right, just like useState. The difference is that the updater is a dispatch that takes an action describing what happened, and a single reducer function decides how the state changes. (useReducer returns “an array with exactly two values: the current state… and the dispatch function.”—react.dev, useReducer.)

Organizing the files

A pattern I lean on heavily: structure your code so it’s obvious where things live, then you import less and read more.

I keep a reducers/ folder in src/. Each reducer maps to a feature, so the file for our filtering logic is movieListFiltering.ts. (Notice the name ties the reducer to the feature, not to some generic “list.”) I deliberately do not spread reducer files all over the component tree—keeping them in one folder stops them from cluttering the React components.

Alongside the reducer I keep the types in an ambient declaration file, movieListFiltering.types.d.ts:

movieListFiltering.types.d.ts
type Rating = "all" | "1" | "2" | "3" | "4" | "5";
type SortBy = "none" | "title" | "rating";
type LocalListState = {
query: string;
minimumRating: Rating;
sortBy: SortBy;
};
type LocalListAction =
| { type: "QUERY_CHANGED"; payload: string }
| { type: "RATING_CHANGED"; payload: Rating }
| { type: "SORT_CHANGED"; payload: SortBy }
| { type: "RESET" };

There’s one snag to internalize. The moment you put the word export in a .d.ts file, TypeScript stops treating that file as ambient and treats it as a module—and then you have to import those types everywhere. The point of an ambient declaration is that TypeScript picks the types up globally and you never import them. So inside a .d.ts that’s meant to stay ambient, use declare type / type and skip export. (If the file has to be a module for some other reason, declare global { ... } gets you the same globally visible types.) The less I have to import, the more readable the file is—and in plain JavaScript you’d be importing constantly, so ambient types are a small luxury I take advantage of.

type vs interface? Mostly personal preference, and it’s fine to mix them—just have a consistent mental rule. I prefer type for two reasons. First, it’s more explicit. Second, I come from a Java background, where interface is a thing you implement, and that connotation leaks into how I read code. There’s also one functional difference that matters here: our LocalListAction is a union of object shapes. You can’t cleanly express a union as a single interface. So even if you reach for interface elsewhere, action types want to be a type.

Wiring up useReducer (with a couple of paranoid safety nets)

With the types in place, the reducer almost writes itself—it’s just a switch over the action types we declared. Here it is, with two safety nets I add as a matter of course:

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;
}
}

Safety net one: state: LocalListState = initialState. If state is ever somehow undefined, the reducer still returns a valid object instead of letting an undefined slip downstream where I won’t catch it. The default just makes me sleep better at night.

Safety net two: as const on initialState, and—even better—Object.freeze(initialState). If anyone, including future-me, tries to mutate the initial state by reference, freezing will turn that write into a loud runtime error (in strict mode, which every ES module already is) instead of a silent, time-traveling bug. I have a lot of gray hairs from that kind of mutation: someone modifies a shared object by changing a value in place, and now the location of the mutation is nowhere near the symptom. You spend an afternoon staring at the component that shows the wrong value, and the culprit is a helper function three files away that “just tweaked one field.” Freezing the initial state is inexpensive insurance against that afternoon.

Then the call site:

const [localState, dispatchLocal] = useReducer(listReducer, initialState);

We now have state and a clean way to update it. But state isn’t what we render—we render the filtered, sorted list. Turning one into the other is where the next decision matters.

Deriving visible movies with useMemo—not an Effect

Now the part I have opinions about.

We need the visible movies: the input movies array, filtered by query and rating, then sorted. That’s derived state—a value computed from existing state and props. Derived state is an answer, not a fact. You don’t keep a sticky note on the fridge that says how many eggs are inside; you open the fridge and count, because the note will lie the moment someone makes an omelet. Storing derived state in useState is writing that sticky note—and the wrong way to do it is the one I see constantly: a useEffect that watches the inputs and calls a useState setter to store the result.

// 🚫 Don't do this
const [visibleMovies, setVisibleMovies] = useState(movies);
useEffect(() => {
setVisibleMovies(filterAndSort(movies, localState));
}, [movies, localState]);

That effect is an abuse of reactivity. You render, commit, run the effect, set state, and re-render—an extra pass on each change, plus a window where visibleMovies is stale. The React docs are blunt about this pattern: “You don’t need Effects to transform data for rendering… transform all the data at the top level of your components.” And when the transform is expensive, “This tells React that you don’t want the inner function to re-run unless [the dependencies] have changed”—i.e. use useMemo. (react.dev, You Might Not Need an Effect.)

So we compute the visible list during render and memoize the result:

const visibleMovies = useMemo(() => {
const query = localState.query.toLowerCase();
const matchingMovies = movies.filter((movie) => {
const matchesQuery = movie.name.toLowerCase().includes(query);
const matchesRating =
localState.minimumRating === "all" ||
movie.rating >= Number(localState.minimumRating);
return matchesQuery && matchesRating;
});
if (localState.sortBy === "title") {
return [...matchingMovies].sort((a, b) => a.name.localeCompare(b.name));
}
if (localState.sortBy === "rating") {
return [...matchingMovies].sort((a, b) => b.rating - a.rating);
}
return matchingMovies;
}, [movies, localState.query, localState.minimumRating, localState.sortBy]);

Two things to call out.

First, the dependency array lists localState.query, localState.minimumRating, and localState.sortBy—the three things this calculation reads—rather than the entire localState object. (More on that choice in a moment.) Even though I “pass them in” via the dependency array, inside the closure I still reference them by their full path—useMemo and useCallback don’t magically scope those names for you. That confuses people, so: you list dependencies and you reference them normally inside.

Second, notice the lowercase trick on both sides of includes. Lowercasing the movie name and the query means the search is case-insensitive—the spiritual equivalent of Java’s compareToIgnoreCase. Capital or lowercase, you just care whether one string contains the other.

A reasonable question came up in the workshop: why not just put [localState] in the dependency array and pass the object itself down? Honestly, in this case, that would be fine—this object only has three fields and all three feed the calculation. So there’d be no downside to speak of.

However, I default to listing the specific fields, and that default pays off the moment the state object grows a fourth field that isn’t part of this calculation. With [localState], any dispatch that produces a new state object—even one that only changes an unrelated field—invalidates the memo and re-runs the work. List the three fields you read, and only changes to those three recompute. When the object is small and each field feeds the calculation, do whatever’s cleanest. When it might grow, be specific. Either way, know why you chose.

(One subtlety about dependencies: the dispatchLocal function from useReducer has a stable identity—it never changes between renders. That’s why you’ll see the dispatch function safely passed around and omitted from dependency arrays. It’s different from useState’s setter in spirit, but the docs confirm dispatch is stable: “The dispatch function has a stable identity.”—react.dev, useReducer.)

Action creators: making dispatch read like English

State, check; derived view, check. The last piece is the wiring from a control back to a dispatch—and there’s a small quality-of-life choice hiding there.

You can dispatch raw action objects:

<input
value={localState.query}
onChange={(e) =>
dispatchLocal({ type: "QUERY_CHANGED", payload: e.target.value })
}
/>

That works, but I prefer to extract each action into a small function that returns the typed action object—an action creator—and keep them in an actions/ folder next to the reducer (Redux Toolkit generates these for you automatically, but we’ll get to that in Part 2):

actions/movieListActions.ts
export function changeQuery(newQuery: string): LocalListAction {
return { type: "QUERY_CHANGED", payload: newQuery };
}
export function changeRating(newRating: Rating): LocalListAction {
return { type: "RATING_CHANGED", payload: newRating };
}
export function changeSort(newSort: SortBy): LocalListAction {
return { type: "SORT_CHANGED", payload: newSort };
}
export function reset(): LocalListAction {
return { type: "RESET" };
}

Now the call site reads like English:

<input
value={localState.query}
onChange={(e) => dispatchLocal(changeQuery(e.target.value))}
/>

dispatchLocal(changeQuery(value)) versus a raw dispatchLocal({ type: "QUERY_CHANGED", payload: ... })—the first one tells you what’s happening at a glance.

There are two more payoffs, both about type safety. The action’s type is a hardcoded string literal, which TypeScript treats as a discriminated union: once you check action.type === "QUERY_CHANGED", TypeScript narrows the action and knows payload is a string. If you ever change a payload’s type—say string becomes Rating—TypeScript will light up each call site that’s now wrong, before you run a thing. And because the construction lives in one function, the day an action’s shape changes you fix it once instead of hunting down the inline object literals one by one.

A note on idiom: dispatching raw objects isn’t wrong. It’s just version one. The instant you feel the friction—repeated shapes, a payload type that’s drifting—switch to functions. I tend to start with functions and skip the friction entirely.

Why immutability is the whole game in a reducer

You may have noticed each case in that reducer spreads ...state into a brand-new object instead of just assigning to a field. The spread is load-bearing, not a style choice, and the why is important.

Strip away the ceremony and a reducer is one idea: given the current state and an action, return the next state—a new object whenever anything changed, never a mutated one. The switch exists only to pick which slice of state to update; the default returns the existing state untouched.

The reason this matters is how JavaScript copies objects. A spread ({ ...state }) is a shallow copy—it makes a new envelope but stuffs it with the same letters. The top-level object is new, but any nested object inside it is still shared by reference. For example:

const original = { sort: "title", filters: { rating: "all" } };
const copy = { ...original };
copy.filters.rating = "5";
console.log(original.filters.rating); // "5"—oops

copy is a fresh top-level object, but copy.filters and original.filters are the same object in memory. Mutate the nested one and you’ve mutated both. For deeper state you have to spread each level you touch:

return {
...state,
filters: { ...state.filters, rating: action.payload },
};

That shared-reference trap is why React (and Redux) are so insistent on immutability. React decides whether a state update did anything by comparing references (Object.is); mutate in place and the reference doesn’t change, so the update is skipped and the UI goes stale without a word of warning. The official Redux guidance puts it plainly: “Mutating state is the most common cause of bugs in Redux applications, including components failing to re-render properly… Actual mutation of state values should always be avoided.” (redux.js.org, Style Guide.)

Our movie-list state is flat—three primitive fields—so a single spread per case is all we need. But the discipline scales: in each case, copy the state, overwrite the slice that changed, and return the rest.

Where does the business logic go?

Keeping the reducer pure raises a design question I get asked in most workshops: should the filtering and sorting logic live inside the reducer (in a VISIBLE_MOVIES_COMPUTED action, say), or outside it the way we did, in a useMemo?

There are two schools of thought, and I use both.

One school keeps all state-shaping logic in the reducer so there’s a single place that knows how this feature’s state evolves. The other keeps reducers dead simple—update this slice, return—and puts derivation in utilities or hooks. I lean toward the second here for a concrete reason: the movie data lives outside this reducer (it’s a prop), so pulling that array into the reducer just to filter it would be odd. Why complicate the reducer with data it doesn’t own?

There’s a tradeoff here, not a winner. Logic in the reducer is harder to debug step-by-step but means each state change funnels through one auditable place. Logic in a useMemo or utility is easier to read and test in isolation. I don’t have an “always do X” rule here. What I do have is pick one and be consistent about it per project. If a codebase already puts business logic in reducers, match it. If it keeps reducers thin, match that. I think consistency beats cleverness; mixing both in one project is a reliable way to make code hard to maintain.

A quick word on the messy edges

Before we zoom back out, two small things you’ll hit the moment you build this outside a demo—and shouldn’t stress about.

When the input data might be missing, optional chaining and the nullish-coalescing operator keep the render safe. For example, favoriteMovieIds?.includes(movie.id) ?? false won’t throw if favoriteMovieIds is undefined.

And don’t be afraid to write the first version a little loose. Inline a handler, repeat a label, leave the action objects raw. Once the pattern emerges you can tighten it up in a cleanup pass—extract the labels into a reusable component, pull the actions into creators. Writing it slightly sloppy first and refining once you see the shape is a perfectly good workflow, and it’s not something I’d push back on in a review.

What’s next

The state in this post lived in one component—no provider, no store. The query, the rating filter, the sort order—they live in one reducer, derived into a memoized view and rendered. And the payoff is concrete: when a useMemo recomputes, you can drop a breakpoint in one place and watch exactly what changed, instead of chasing a pile of effects racing each other to overwrite state.

In Part 2 we cross into shared state—favorites, a header counter, a fetched config—and see when Redux Toolkit stops being overkill. That’s where slices, the store, async thunks, and the idle/loading/succeeded/failed status pattern come in: favorite movies persisted on the client with no backend required, a favorites counter in the header that has to react from anywhere, and a shared config fetched once and read by each movie card to build image URLs.

If you take one thing from Part 1, take the classification step. Private, related, or shared? Answer that first, and most of the time the answer is “smaller than you thought.”