+1 (781) 556-6029

Retiring a Redux store one slice at a time

The request usually arrives as "we want to get off Redux". The store is 40 slices, a third of them are loading/error/data triples, onboarding a new engineer takes two weeks, and someone has read that modern React does not need a global store. That may be true. It is not a reason to start deleting reducers.

We do not sell rewrites, and a state-management rewrite is the one we refuse most often. The store is where the application's behavior accumulated; replacing it wholesale means replacing behavior you have not written down. What works instead is boring: classify what is in the store, move the categories that have a better home, leave the ones that do not, and ship each move on its own.

First, classify the slices

Open the root reducer and put every slice into one of four buckets. This takes an afternoon and it is the entire decision.

Server cache. Data fetched from an API and held so components can read it. Tell-tale shape: a slice with data, isLoading, error, and a thunk that sets all three. Also tell-tale: a bug ticket about stale data after a mutation, and a refetchAll() somewhere on route change.

URL state. Filters, sort order, pagination, selected tab, open detail panel. Tell-tale: a slice whose values a user would reasonably expect to survive a page reload or appear in a shared link, and does not.

Form state. Draft values for an editor or a multi-step flow, dispatched on every keystroke. Tell-tale: actions named setFieldValue, and a performance complaint about typing latency.

Real client state. Things with no server and no URL that genuinely span distant parts of the tree: authenticated session, feature flags, theme, a notification queue, an undo stack, the contents of a collaborative editing session, a websocket connection's status.

In the codebases we review, the last bucket is usually three to six slices. The other thirty are in the first three. That ratio is the finding, and it reframes the work: this is not a migration off Redux, it is moving cached server data out of a client store. Once that is done, the remaining store is small enough that whether it stays Redux is a low-stakes question.

Move the server cache first

This is the largest bucket and the one with the clearest replacement. A query library — TanStack Query is the one we reach for, or the framework's own loader/fetch layer if you are on Next.js App Router or React Router in framework mode — gives you deduplication, caching by key, background revalidation, and invalidation on mutation. Those are the four things hand-rolled thunks get wrong, in that order.

Run it slice by slice, one pull request each:

  1. Pick the slice with the fewest consumers, not the most annoying one. The first increment is where you establish query-key conventions, and you want to be able to change your mind cheaply.
  2. Write the query hook next to the feature that owns the endpoint. Keep the key structure explicit and hierarchical — ['invoices', 'list', filters] and ['invoices', 'detail', id] — so invalidating ['invoices'] after a mutation is one call and not a guessing game.
  3. Convert the consumers from useSelector to the new hook. Leave the old slice registered and untouched.
  4. Delete the slice, its thunk, and its actions in the same PR once no consumer imports them. A grep, not a memory.
  5. Watch request volume in the network panel or your RUM data for a day. Query libraries change fetch timing — refetchOnWindowFocus and staleTime defaults will either cut requests sharply or raise them, depending on what the thunks were doing, and you want to know which.

Two things that go wrong at this stage. First, teams wrap the query library in a thin abstraction "in case we switch" and lose the hook ergonomics that were the point; do not. Second, they copy the cached result back into Redux so existing selectors keep working. That is two sources of truth for the same bytes, which is the bug you started with, now with an extra layer.

If you are on a Server Components architecture, some of this bucket disappears rather than moves: data that is read-mostly and needed at first paint belongs in a server component, fetched on the server, with no client cache entry at all. Keep the query library for what is actually interactive — data that mutates, polls, or paginates in response to the user.

Put URL state in the URL

Filters and pagination in a store are a category error, and moving them is usually a net deletion of code. useSearchParams in Next.js or React Router gives you shareable links, working back-button behavior, and reload survival for free — three bug classes closed by a refactor that removes a reducer.

The one real cost is that the URL is a string keyed by convention, so serialization needs a single owner. Write one module per route that parses search params into a typed object and serializes back, validate it (a zod schema or a hand-written parser, either is fine), and have components read the parsed object. Without that, param parsing scatters across six components and drifts.

One behavioral detail worth testing: pushing a new history entry on every filter change makes the back button walk through every keystroke. Replace rather than push for high-frequency changes, and push for ones a user would think of as navigation.

Let form state be local

Drafts dispatched to a global store on every keystroke are the usual cause of typing latency in an admin UI: each keystroke updates the store, every connected consumer is notified, and a subscriber somewhere up the tree does non-trivial work. Move the draft into the form component — useState, or a form library if the validation is real — and dispatch or mutate once on submit.

The objection is autosave and cross-step persistence. Both are fine without a global store: keep the draft local, persist it with a debounced mutation or to localStorage, and hold multi-step wizard state in the wizard's own provider rather than the app store. As with everything here, profile the interaction before and after; if input latency does not improve, the store was not the problem and you should stop and find out what is.

Then decide about what is left

When the three moveable buckets are gone, look at the remainder honestly. A handful of slices holding session, flags, and a notification queue is a well-sized Redux store. Redux Toolkit is maintained, your team knows it, the devtools and time-travel debugging are genuinely useful for state machines, and there is no business case for touching it. "You may not need us" applies to your own refactor backlog too: stopping here is a legitimate outcome, and it is the one we recommend more often than not.

The cases where we do finish the move: the remaining state is small and simple enough that the boilerplate is pure overhead, or it is genuinely fine-grained and high-frequency — cursors, canvas interactions, collaborative presence — where per-atom subscriptions in something like Zustand or Jotai avoid a class of render work Redux's single-store subscription makes awkward. Both are engineering arguments with a measurement behind them. "Redux is old" is not.

If you do replace the remainder, note that the new library is not the risky part. The risky part is that connect-era code often has reducer logic that nothing documents — a derived flag three components depend on, an action that quietly resets two other slices. Before you delete a reducer, write a test that asserts its behavior through the UI. That test is what makes the replacement a refactor rather than a rewrite.

What the sequence buys you

Each step above merges to main on its own, is revertable on its own, and closes a specific complaint: stale data after mutations, filters that do not survive a reload, slow typing, long onboarding. None of them require a branch that lives for a quarter.

It also means the honest answer to "should we get off Redux" is often no — and that you will know that six weeks in, having already shipped the improvements, rather than three months into a rewrite that has not shipped anything.