Migrating a Legacy Redux Codebase to Redux Toolkit with Minimal Risk
Migrate a legacy Redux codebase (createStore, action constants, switch reducers) to Redux Toolkit incrementally — one slice at a time — without changing your state shape or breaking existing tests.
13 Mar 2026, 02:06 UTC

A legacy Redux codebase — hand-written action type constants, switch-statement reducers, createStore with applyMiddleware — works, but every new feature means touching four files and duplicating boilerplate. Redux Toolkit (RTK) collapses that into configureStore, createSlice, and createAsyncThunk. The useful part: you can migrate incrementally, one slice at a time, without changing your state shape or breaking existing tests. This guide assumes Redux v4+, Node.js 14+, and an existing test suite covering your reducers.
What you're aiming for
The end state is a store built with configureStore where each domain of state is a createSlice slice, async work goes through createAsyncThunk, and components consume state through typed hooks. Crucially, the shape of the state tree stays identical, so selectors and persisted state keep working. That constraint is what makes the migration low-risk: if a snapshot of your state before and after matches, you've preserved behavior.
Prerequisites
- Redux v4 or later already installed, with react-redux v7+ (v8 recommended for the typed hooks pattern).
- A test suite that exercises your reducers and async flows — this is your safety net.
- Basic familiarity with Immer, the library RTK uses internally so you can write "mutating" reducer logic that produces immutable updates.
- TypeScript users: strict mode enabled if you want the full inference benefit.
Step 1: Install and swap the store
Install the package (run in your project root, no special permissions needed):
npm install @reduxjs/toolkitThen replace createStore with configureStore, passing your existing root reducer unchanged:
// store.js — before
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(
rootReducer,
compose(applyMiddleware(thunk, logger), window.__REDUX_DEVTOOLS_EXTENSION__?.())
);// store.js — after
import { configureStore } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import rootReducer from './reducers';
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});Two things change by default here. First, configureStore includes redux-thunk and wires up Redux DevTools automatically — remove your manual setup. Second, it adds development-only middleware that warns on non-serializable state values (Date objects, class instances) and accidental mutations. If you have known non-serializable fields, silence the warning explicitly rather than disabling the check:
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: { ignoredPaths: ['session.expiresAt'] },
}).concat(logger),At this point the app should run exactly as before. Run your full test suite and a manual smoke pass before touching any reducers.
Step 2: Convert reducers to createSlice, one at a time
Pick one domain — say, a cart reducer — and convert it. A legacy reducer with action type constants:
// legacy
const ADD_ITEM = 'cart/addItem';
export const addItem = (item) => ({ type: ADD_ITEM, payload: item });
export default function cartReducer(state = [], action) {
switch (action.type) {
case ADD_ITEM:
return [...state, action.payload];
default:
return state;
}
}becomes:
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: [],
reducers: {
addItem(state, action) {
state.push(action.payload); // Immer makes this immutable
},
},
});
export const { addItem } = cartSlice.actions;
export default cartSlice.reducer;Wire the new slice reducer into the root reducer in place of the old one. The most common migration bug lives here: inside createSlice you either mutate the draft or return a brand-new state — never both, and never return a modified draft. Immer will throw if you mix them.
Keep the action type strings stable if anything outside the slice dispatches or listens for them. By default createSlice generates types like cart/addItem; if your legacy constant was 'cart/addItem', you're fine. If it was something like 'CART_ADD_ITEM', either update the listeners or override the type with a custom action creator.
Step 3: Move async flows to createAsyncThunk
Hand-rolled thunks that dispatch REQUEST/SUCCESS/FAILURE triplets convert directly. createAsyncThunk auto-generates pending, fulfilled, and rejected action types, and you handle them in the slice's extraReducers:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchCart = createAsyncThunk(
'cart/fetchCart',
async (userId, { rejectWithValue }) => {
const res = await fetch(`/api/users/${userId}/cart`);
if (!res.ok) return rejectWithValue(res.status);
return res.json();
}
);
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [], status: 'idle', error: null },
reducers: { /* ... */ },
extraReducers: (builder) => {
builder
.addCase(fetchCart.pending, (state) => { state.status = 'loading'; })
.addCase(fetchCart.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchCart.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? action.error.message;
});
},
});Delete the old action type constants for these flows. If you leave both the legacy constants and the generated lifecycle actions, you'll end up with duplicate handling or dead code that's hard to audit. Sagas and other custom async middleware can stay in place during the transition — migrate them flow by flow.
Step 4: Typed hooks for TypeScript
Create a dedicated hooks file to avoid circular imports between the store and components:
// app/hooks.ts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;// app/store.ts
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;Then sweep components from the untyped useDispatch/useSelector to these. This is mechanical and safe to do last.
Verifying the migration
- Run the existing suite (
npm test). Reducer snapshot tests should pass unchanged — that's the point of preserving state shape. - Run
tsc --noEmitunder strict mode; there should be no newanycasts. - Open Redux DevTools and trigger an async flow: you should see
cart/fetchCart/pendingthenfulfilled(orrejected) with correct payloads. - Watch the console for serializability or mutation warnings from the default middleware — these surface latent bugs the old store never told you about.
- Manually regression-test critical flows: auth, data fetching, anything with persisted or rehydrated state.
Recovery options if something breaks
Because the migration is incremental, rollback is granular. Keep the legacy reducer file until its slice's replacement is validated; reverting one slice is a one-line change in the root reducer. If configureStore itself is suspected, you can temporarily go back to createStore from the redux package while keeping slices — createSlice reducers are plain reducers and work in any Redux store. For larger apps, put the new store behind a feature flag or migrate route by route. If Immer's draft mutation causes confusion on the team, the built-in immutability-check middleware (on by default in development) will catch genuine mistakes.
The realistic order of operations is: store first, verify; slices one at a time, verify each; async flows next; typed hooks last. Each step leaves the app fully functional, so the migration can pause anywhere without leaving the codebase in a broken in-between state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.