Redux Toolkit's createSlice: An Engineering Trade-off Worth Making
Manual action constants and switch statements add maintenance overhead. Redux Toolkit's createSlice automates them, but the shift to Immer and async thunks introduces real trade-offs.
14 Feb 2026, 20:19 UTC

Hand-writing a Redux reducer often means maintaining three parallel lists: action type constants, action creator functions, and a switch statement that maps them together. Rename one action, and you update three places. Add a new case, and you touch the same three places again. For teams managing more than a couple of state domains, that repetition turns into a maintenance tax that has little to do with the actual product logic.
Redux Toolkit's createSlice API is an engineering decision to pay that tax upfront—in bundle size and a slightly different mental model—in exchange for reducers that stay readable as they grow. This article examines what that trade looks like in practice, using Redux Toolkit 2.x as the reference version.
The Parallel Lists Problem
In a hand-written Redux setup, a single feature typically spans multiple declarations. You define string constants for action types, write functions that return action objects, and then write a reducer that switches on those constants. Every addition or rename ripples across all three.
The table below contrasts that manual pattern with what createSlice generates.
| Manual Redux | createSlice |
|---|---|
| Separate action type constants | Generated from name plus the case key |
| Separate action creator functions | Exported automatically from the slice |
| Switch statement in reducer | Object methods under reducers |
| Manual immutable spreads | Immer draft mutations |
What createSlice Actually Does
A slice is a single object that groups the reducer logic, action types, and action creators for one feature. You provide a name, an initialState, and a reducers object whose keys become case names. Redux Toolkit derives action type strings such as preferences/setTheme and creates matching action creators for you.
Under the hood, createSlice integrates Immer. Immer lets you write code that looks like direct mutation—state.theme = 'dark'—but produces a new immutable state object using structural sharing. You are mutating a draft, not the actual state. This removes the need to manually spread nested objects and arrays, which is where most hand-written reducers accumulate noise.
Redux Toolkit also bundles development defaults. The Redux DevTools Extension is enabled by default, and development builds include checks for accidental state mutations and non-serializable values in the store.
A Worked Example
Consider a user preferences slice that stores a theme and language, plus an async action to persist those settings to a server. Run the following in your project root to install the required packages:
npm install @reduxjs/toolkit react-redux
Where to run it: your terminal, inside the React project directory.
Required permissions: write access to node_modules and package.json.
Placeholder: replace your-app with your actual project path if you are scripting the install.
Expected check: after installation, confirm that @reduxjs/toolkit appears in your package.json dependencies.
Risk: mixing versions of react-redux and @reduxjs/toolkit can cause peer dependency warnings; align them with your React version.
Create the slice file, for example at src/features/preferences/preferencesSlice.js:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const savePreferences = createAsyncThunk(
'preferences/save',
async (preferences, { rejectWithValue }) => {
try {
const response = await fetch('/api/preferences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(preferences),
});
if (!response.ok) throw new Error('Save failed');
return await response.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const preferencesSlice = createSlice({
name: 'preferences',
initialState: {
theme: 'light',
language: 'en',
saveStatus: 'idle',
error: null,
},
reducers: {
setTheme(state, action) {
state.theme = action.payload;
},
setLanguage(state, action) {
state.language = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(savePreferences.pending, (state) => {
state.saveStatus = 'loading';
state.error = null;
})
.addCase(savePreferences.fulfilled, (state) => {
state.saveStatus = 'succeeded';
})
.addCase(savePreferences.rejected, (state, action) => {
state.saveStatus = 'failed';
state.error = action.payload;
});
},
});
export const { setTheme, setLanguage } = preferencesSlice.actions;
export default preferencesSlice.reducer;
In this example, setTheme and setLanguage are generated automatically. Dispatching setTheme('dark') produces an action with the type preferences/setTheme. The async thunk savePreferences dispatches three lifecycle actions: pending, fulfilled, and rejected. The extraReducers builder listens for those actions without requiring you to write separate constants for each.
Practical check: after wiring the reducer into your store, open Redux DevTools and dispatch setTheme('dark'). Confirm that the action type reads preferences/setTheme and that the theme value updates while the rest of the state object remains reference-equal where unchanged.
Trade-offs and Limitations
Bundle size
Redux Toolkit adds more code than a minimal hand-written Redux setup because it includes Immer, thunk middleware, and development checks. The increase is usually modest, but it is measurable. Run a bundle analysis tool such as webpack-bundle-analyzer to compare the output before and after adoption.
The Immer learning curve
Developers new to Immer may find the mutable syntax confusing. Assignments like state.error = null look dangerous if you are used to returning new objects. The safety comes from Immer's draft system, but teams need to understand that returning the draft directly, or mutating values outside the draft, can break the update.
Debugging async thunks
Because createAsyncThunk dispatches multiple actions for a single logical operation, tracing a failed request means correlating the pending, fulfilled, and rejected entries in DevTools. Plain Redux does not include promise lifecycle logging out of the box; you may need additional middleware or careful extraReducers structure to see why a request failed.
When to Adopt It
If your application has more than two or three reducers, or if you manage any asynchronous data flows, the boilerplate reduction from createSlice usually outweighs the bundle cost. Start by converting one slice. Verify the generated action types in DevTools, write a unit test that asserts the reducer returns a new state object, and measure the bundle delta with an analyzer. If the size increase is acceptable for your constraints, the pattern scales cleanly as features grow.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.