Skip to main content

Command Palette

Search for a command to run...

Migrating a Legacy Redux Codebase to Redux Toolkit: A Phased Plan That Doesn't Stop Feature Work

Updated
•9 min read•View as Markdown
Migrating a Legacy Redux Codebase to Redux Toolkit: A Phased Plan That Doesn't Stop Feature Work

You don't need a rewrite to get off legacy Redux. You need a sequence that lets old and new code run side by side, so the team keeps shipping features while the codebase gets cleaner every sprint.

This is the plan I use when a team hands me a 3–5 year old React app with createStore, a constants/ folder full of action types, connect() on every container, and a few hundred lines of sagas nobody wants to touch.

Why bother migrating at all?

Legacy Redux isn't broken. It's expensive. Every new feature costs four or five files (constants, action creators, reducer, saga, container), and each one is another place for a bug. Hand-written immutable updates break quietly. New hires take weeks to learn a pattern the rest of the ecosystem dropped years ago.

Redux Toolkit cuts that to one slice file per feature, and RTK Query removes most data-fetching code entirely.

The risk is doing it the wrong way: a big-bang rewrite on a long-lived branch that never merges.

The six phases at a glance

Phase What changes Risk Feature work during it
0. Inventory and safety net Nothing in production None Normal
1. Swap the store createStore → configureStore Low Normal
2. Freeze the old pattern New code uses RTK only None Normal
3. Convert reducers to slices One slice per PR Low–medium Normal
4. Move server data to RTK Query One endpoint group at a time Medium Slightly slower
5. Retire sagas and connect() As files are touched Low Normal

Phase 0: Inventory and a safety net

Before changing anything, measure the job. A few searches tell you its size:

# connected components
grep -rl "connect(" src | wc -l
# saga files
grep -rl "redux-saga/effects" src | wc -l
# hand-written action type constants
grep -rhoE "export const [A-Z_]+ = '" src | wc -l
# reducers using switch statements
grep -rl "switch (action.type)" src | wc -l

Write those numbers down. They're your progress tracker.

Then add a thin layer of integration tests around the 3–5 flows that make money: checkout, sign-up, the core dashboard. Render with a real store, mock the network with MSW, and assert on what the user sees. Those tests survive the migration because they don't care how the state is wired. The setup is in Mastering Jest Day 6: Testing Redux Toolkit.

Check your versions too. React-Redux 9 needs React 18 or later. If you're on React 16 or 17, upgrade React first as its own project.

Phase 1: Swap createStore for configureStore

This is the smallest change with the biggest payoff, and your existing reducers drop straight in.

Before

import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import createSagaMiddleware from 'redux-saga'
import thunk from 'redux-thunk'
import rootSaga from './sagas'
import { user, cart, orders } from './reducers'

const sagaMiddleware = createSagaMiddleware()
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose

export const store = createStore(
  combineReducers({ user, cart, orders }),
  composeEnhancers(applyMiddleware(thunk, sagaMiddleware))
)
sagaMiddleware.run(rootSaga)

After

import { configureStore } from '@reduxjs/toolkit'
import createSagaMiddleware from 'redux-saga'
import rootSaga from './sagas'
import { user, cart, orders } from './reducers'

const sagaMiddleware = createSagaMiddleware()

export const store = configureStore({
  reducer: { user, cart, orders },
  middleware: (getDefault) => getDefault().concat(sagaMiddleware),
})
sagaMiddleware.run(rootSaga)

Thunk and DevTools are now built in. You also get two development-only checks: one for accidental state mutation and one for non-serializable values such as Dates, class instances or functions in state.

Expect those checks to fire. They're finding real bugs that were already there. If there are too many to fix at once, relax them for now and track the warnings as tickets:

middleware: (getDefault) =>
  getDefault({ serializableCheck: false, immutableCheck: { warnAfter: 128 } }).concat(sagaMiddleware),

Turn them back on before phase 3 ends.

Phase 2: Freeze the old pattern

One rule from now on: new features use createSlice, hooks and RTK Query. No new constants, no new connect(), no new sagas.

Put it in your PR template and lint for it. ESLint's no-restricted-imports can flag redux-saga/effects and connect in new files. This stops the legacy surface from growing while you shrink it.

Phase 3: Convert reducers to slices, one per PR

Pick the slice with the most churn first. That's where developers feel the improvement soonest.

Before: three files

// constants/cart.js
export const ADD_ITEM = 'ADD_ITEM'
export const REMOVE_ITEM = 'REMOVE_ITEM'

// actions/cart.js
export const addItem = (item) => ({ type: ADD_ITEM, payload: item })
export const removeItem = (id) => ({ type: REMOVE_ITEM, payload: id })

// reducers/cart.js
export default function cart(state = { items: [] }, action) {
  switch (action.type) {
    case ADD_ITEM:
      return { ...state, items: [...state.items, action.payload] }
    case REMOVE_ITEM:
      return { ...state, items: state.items.filter((i) => i.id !== action.payload) }
    default:
      return state
  }
}

After: one file

// features/cart/cartSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'

export const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] as CartItem[] },
  reducers: {
    addItem(state, action: PayloadAction<CartItem>) {
      state.items.push(action.payload)
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter((i) => i.id !== action.payload)
    },
  },
})

export const { addItem, removeItem } = cartSlice.actions
export default cartSlice.reducer

The trap: action type strings change

createSlice names actions cart/addItem, not ADD_ITEM. Any saga, middleware or other reducer still listening for 'ADD_ITEM' silently stops working.

Two safe ways through:

  1. Re-export from the old paths. Point actions/cart.js at the new action creators, so every existing dispatch(addItem(...)) gets the new type automatically. Then search for raw 'ADD_ITEM' strings in sagas and other reducers, and replace them with addItem.type.

  2. Accept both types during the transition. If old string types are dispatched from places you can't change yet, handle them in extraReducers:

extraReducers: (builder) => {
  builder.addCase('ADD_ITEM', (state, action: PayloadAction<CartItem>) => {
    state.items.push(action.payload)
  })
},

Delete that compatibility case once the search comes back empty.

Phase 4: Move server data to RTK Query

This is where most of the code disappears. A typical legacy fetch flow is a request/success/failure constant trio, three action creators, a saga or thunk, and a reducer holding data, loading and error, roughly 80–150 lines per endpoint.

In RTK Query, the same endpoint is about five lines:

getOrders: build.query<Order[], void>({
  query: () => 'orders',
  providesTags: ['Order'],
}),

Migrate one endpoint group at a time (all of "orders", say), behind the same component interface:

  1. Add the endpoint to your createApi service.

  2. Swap the component's useSelector + useEffect(dispatch(fetchOrders())) for useGetOrdersQuery().

  3. Use invalidatesTags on mutations, so you can delete the manual "refetch after save" logic.

  4. Once nothing reads the old orders reducer, delete it with its saga and constants.

This phase is slower because each endpoint deserves a quick manual check of loading and error states. It's also the phase that pays off most.

Phase 5: Retire sagas and connect()

connect(). It's still supported, so there's no rush. Convert a component to useAppSelector and useAppDispatch whenever someone touches the file anyway. Mechanical conversions are a good use of an AI coding assistant, as long as the integration tests from phase 0 run on every PR.

Sagas. Sort them into three groups:

  • Data fetching sagas → already gone after phase 4.

  • "When X, do Y" sagas (analytics, toasts, syncing to localStorage) → move to createListenerMiddleware.

  • Genuinely complex flows (cancellable polling, multi-step wizards with race conditions) → keep them. Saga is good at this. Migrate them only if removing the dependency is a goal in itself.

// before: a saga
function* watchItemAdded() {
  yield takeEvery(ADD_ITEM, function* (action) {
    yield call(trackEvent, 'add_to_cart', action.payload.id)
  })
}

// after: a listener
listener.startListening({
  actionCreator: addItem,
  effect: async (action) => {
    await trackEvent('add_to_cart', action.payload.id)
  },
})

How long does it take?

It depends on the size of the app and how much of the team's time goes to it. As a rough guide, assuming one senior engineer at about half-time alongside normal feature work:

App size (from your phase 0 counts) Rough timeline
Under 10 reducers, a few sagas 2–4 weeks
10–30 reducers, 20+ sagas 1–2 months
30+ reducers, heavy saga use, no tests 2–4 months, starting with the tests

The biggest variable isn't the Redux code. It's whether a safety net of tests exists before you start.

Checklist

  • [ ] Phase 0 counts recorded, and integration tests around the critical flows

  • [ ] configureStore in place, dev checks on or tracked as tickets

  • [ ] "No new legacy Redux" rule in the PR template and lint config

  • [ ] Every reducer converted to createSlice, no raw action type strings left

  • [ ] Server data on RTK Query, old fetch reducers and sagas deleted

  • [ ] Remaining sagas reviewed: migrated, or kept on purpose

Want a migration plan for your codebase?

Every legacy Redux app has its own traps: an undocumented saga that keeps a WebSocket alive, a reducer three other features secretly depend on. In my Architecture Audit (1–2 weeks), I go through your codebase and hand you this plan, adapted to your app: what to migrate first, what to leave alone, and a realistic timeline. If you'd rather have the work done too, my monthly retainer gives you a senior engineer for it while your team keeps shipping.

Book a free consultation →