DEV Community

Cover image for Redux Refactoring Playbook: From Action-Types Soup to RTK Query — Without Rewriting the App
Veaceslav
Veaceslav

Posted on

Redux Refactoring Playbook: From Action-Types Soup to RTK Query — Without Rewriting the App

Every React codebase of a certain age has one: the legacy Redux store. Hundreds of thunk actions, manually managed loading flags, per-feature cache invalidation, and a polling timer per data type pulling the full payload whether anything changed or not.

Ours lived under a real-time monitoring dashboard tracking 1,000+ GSM installations at Orange Romania — and by the time we migrated to RTK Query, the story ended with dashboard responsiveness up ~40%, telemetry traffic down 20%, and "frozen dashboard" complaints down 30%.

The previous article told that performance story. This one is the refactoring mechanics: in what order things moved, which decisions unlocked the next one, and the one rule that made a no-rewrite migration possible.

Rule 1 — Strangle, don't rewrite

The store wasn't broken in one place; it was broken everywhere: a switch-case reducer file nobody fully read, five middleware, isLoading / isRefreshing / hasError flag families per resource, and component-level useEffect chains firing fetches in a specific order "because it worked."

A big-bang rewrite was a non-starter: the dashboard was used every day by operations teams. So the rule was:

One page migrates per PR. The rest of the app keeps running on the legacy store untouched.

Redux makes this possible because combineReducers is natural strangler scaffolding:

const rootReducer = combineReducers({
  // new pages live here
  ordersApi: ordersApi.reducer,          // RTK Query
  invoices: invoicesSlice.reducer,       // RTK slice
  // legacy pages keep their ducks until their turn
  legacyTelemetry: legacyTelemetryReducer, // switch-case reducer, retired last
});
Enter fullscreen mode Exit fullscreen mode

Each migration PR carried its own before/after render counts and INP p75 from a fixed scripted interaction. If the numbers moved the wrong way, the PR didn't merge — refactoring without a measurement loop is just rewriting with extra steps.

The code was the easy half. The pitch mattered as much: I sold this as risk control, not cleanup. Every PR shippable alone meant no feature-freeze window that anyone had to buy; the cost I did have to name up front was the parallel-store tax: two stores side by side meant duplicated fixtures and slower reviews while a slice waited for its turn. Leadership signed off because every migration PR ended with a measurement, not with the word "done". One more org-side lesson: the change-flag contract needed a backend buy-in, and I opened that conversation politely, late. Open it on day one; that delay cost us a month.

Rule 2 — Selectors are the API. Freeze them first.

The trick that removed 90% of the migration risk: components never touched state shapes directly.

The legacy store had selector functions (selectOrders, selectNodeById, selectIsNodeActive). Before migrating anything, we froze them as the contract and then made each selector a re-export shim that could source data from either world:

// selectors/orders.js
import { legacyOrders } from './legacySelectors';
import { useGetOrdersQuery } from '../api/ordersApi';

export function useOrders(selector) {
  const { data, isFetching } = useGetOrdersQuery();
  // legacy pages keep working against the same contract
  return useMemo(() => selector(data ?? legacyOrders()), [data, selector]);
}
Enter fullscreen mode Exit fullscreen mode

(Simplified — the real shims carried fallbacks during the window when a page's data half-lived in the old store. The point is the shape: the component-facing API never moved, only its plumbing did.)

Result: a "migration PR" changed the data layer under a page, not forty components over it. That made page-by-page migration realistic rather than a nice idea.

Rule 3 — Kill loading flags with RTK Query, not with better thunks

There was a middle step available: keep the thunks and clean them up. I'd have kept every race. Request A finishing after request B silently overwrote fresher data, and that was our most common production bug, because each flag family carried its own private version of it.

So server state moved into RTK Query, and components declare what they need, not how to fetch it:

// before: every call site, every page
dispatch(fetchOrders());
// + component-local: isLoading, ordersError, lastRefreshed...

// after: the component declares the requirement
const { data: orders, isLoading } = useGetOrdersQuery(undefined, {
  pollingInterval: 0, // see Rule 4 — we don't poll blindly
});
Enter fullscreen mode Exit fullscreen mode

Seventeen loading-flag families disappeared in this step. After it, a component tracking its own fetch lifecycle existed in exactly one place: a page that legitimately needed manual control.

Rule 4 — Polling moves from timers to tags

Old polling: one setInterval per data type, full payload every tick. On a dashboard watching 1,000+ nodes, that's thousands of identical requests an hour and constant re-render churn over data that hadn't changed.

New polling is no polling — it's a change-flag endpoint: a ten-byte version string the dashbo
ard polls on a timer, and the real data refetches when that string actually changes.

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Nodes'],
  endpoints: (build) => ({
    nodesChanged: build.query<{ version: string }, void>({
      query: () => 'nodes/changed',       // returns a version string only
    }),
    getNodes: build.query<NodeState[], void>({
      query: () => 'nodes',
      providesTags: ['Nodes'],
    }),
    // refetch tagged data only when the flag version changes:
    // a listener middleware polls `nodesChanged` (cheap, ~10 lines) and,
    // on version change, dispatches api.util.invalidateTags(['Nodes'])
  }),
});
Enter fullscreen mode Exit fullscreen mode
const flagListener = createListenerMiddleware();

flagListener.startListening({
  predicate: (action, currentState, previousState) => {
    // the version lives in RTK Query's own cache, so the store
    // snapshot gives us both "current" and "what it used to be"
    const cur = nodesChanged.select()(currentState)?.data;
    const prev = nodesChanged.select()(previousState)?.data;
    return cur != null && cur !== prev;
  },
  effect: (action, api) => {
    api.dispatch(api.util.invalidateTags(['Nodes']));
  },
});

// the flag endpoint itself: cheap on purpose
//   query: () => 'nodes/changed',   // returns ~10 bytes: a version string
//   pollingInterval: 5000,
Enter fullscreen mode Exit fullscreen mode

What happens on an update, end to end: the version goes into the cache and
predicate sees it differ from the previous snapshot, invalidateTags marks
every tagged entry stale without removing it (the screen keeps the old
data), mounted subscribers immediately refetch — deduplicated to one request
per endpoint+args no matter how many components listen — and one re-render
lands the new data. A component sees isLoading: false, isFetching: true
during that window, which is why nothing flashes. If the flag endpoint dies,
data simply goes stale until the next successful tick; nothing breaks. If
nodes changed five times between two ticks, one refetch covers it: the
version is a snapshot of "what is true now", not an event queue.

What stayed in Redux core

A mistake we avoided (mostly): RTK Query is for server state only. UI-owned state — selected node, filter panel state, column layout, wizard steps — stayed in plain createSlice. Migrating that into queries would have replaced a working pattern with a worse one.

The end state: slices for decisions, RTK Query for data, zero custom cache code, zero hand-rolled loading flags.

The honest ledger

From the published case, measured as perceived dashboard responsiveness (how fast fresh data lands after a change, without UI-thread churn):

  • Dashboard responsiveness: +~40%
  • "Frozen dashboard" complaints: −30%
  • REST telemetry traffic: −20%

What I can't give you is a LOC table with a precise count — we deleted more code than we wrote every migration PR, and by the end the store directory had visibly halved; I kept no exact tally, and a made-up precision here would be dishonest.

What I'd do differently

  • Start with RTK Query for any new page. Half the migration tail was legacy thunks for features that could have been endpoints from day one.
  • Freeze selectors before touching anything. The component-facing API is the migration's unit of safety.
  • Measure per PR, not per epic. The scripted interaction + INP check cost minutes per PR and caught two regressions a "it looks fine" test never would.
  • Write the change-flag backend contract early. The polling redesign was the single biggest win and it needed a backend buy-in — politeness about it cost us a month.

Your refactoring checklist

  1. Inventory actions/thunks/loading flags — count them, this is your "before" baseline.
  2. Freeze selectors as the component-facing contract.
  3. Add RTK Query via combineReducers alongside the legacy store.
  4. Migrate one page per PR; measure INP p75 + render count on a fixed interaction.
  5. Server state → endpoints with tags; UI state stays in slices.
  6. Replace timers with a change-flag contract before touching fetch logic.
  7. Delete: action-type strings, flag families, custom cache code. The deletion count is the score.

The performance numbers come from the same production system as my earlier piece, A 40% Faster Fault-Detection Pipeline Without WebSockets — that one tells the polling-protocol story; this is the data-layer refactoring around it.

Top comments (0)