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:
Re-export from the old paths. Point
actions/cart.jsat the new action creators, so every existingdispatch(addItem(...))gets the new type automatically. Then search for raw'ADD_ITEM'strings in sagas and other reducers, and replace them withaddItem.type.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:
Add the endpoint to your
createApiservice.Swap the component's
useSelector+useEffect(dispatch(fetchOrders()))foruseGetOrdersQuery().Use
invalidatesTagson mutations, so you can delete the manual "refetch after save" logic.Once nothing reads the old
ordersreducer, 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
[ ]
configureStorein 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.





