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:
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:
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 thisconst [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):
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"—oopscopy 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.”
Related in this series
- Intro to React and File Organization—where the series began
- Designing Good Custom React Hooks
- Differences Between useMemo and useCallback
- State Management in React, Part 2: Redux Toolkit and the Store—the continuation of this post