Simplifying Redux with createSlice: Less Boilerplate, Safer Mutations
Learn how Redux Toolkit's createSlice cuts boilerplate, lets you write mutable‑style reducers safely with Immer, and integrates async thunks—plus the trade‑offs to watch.
24 Aug 2026, 03:10 UTC

The Redux boilerplate problem
When you start a Redux store, you typically write action type constants, action creators, and a reducer function that switches on those types. For even a modest feature this creates several files and a lot of repetitive code. Keeping the reducer pure (no direct mutations) also forces you to copy state with spread operators or libraries like Immutable.js, which can be error‑prone and noisy.
What createSlice solves
Redux Toolkit’s createSlice API bundles the three pieces—action types, action creators, and reducer—into a single call. It also integrates Immer, so you can write mutating‑style code inside reducers while the library guarantees an immutable result.
Automatic action creators and types
By giving each reducer case a name, createSlice generates the corresponding action type (sliceName/caseName) and action creator automatically. You no longer need to maintain a separate constants file.
Immer‑powered mutable reducers
Inside the reducer you can write code like state.todos.push(action.payload). Immer wraps the state in a Proxy, records the mutations, and produces a new state object when the reducer finishes. If you attempt the same pattern outside of a createSlice reducer, you will mutate the actual state, which is why the safety is scoped to the API.
Worked example: a todo slice
The following slice defines synchronous reducers for adding and toggling todos, and uses createAsyncThunk to fetch a list from an API.
// todoSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// Async thunk for fetching todos
const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue }) => {
try {
const resp = await fetch('/api/todos');
if (!resp.ok) throw new Error('Network error');
return await resp.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const todoSlice = createSlice({
name: 'todos',
initialState: { list: [], loading: false, error: null },
reducers: {
addTodo(state, action) {
// Mutating style thanks to Immer
state.list.push({ id: Date.now(), text: action.payload, completed: false });
},
toggleTodo(state, action) {
const todo = state.list.find(t => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.list = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || action.error.message;
});
},
});
export const { addTodo, toggleTodo } = todoSlice.actions;
export { fetchTodos };
export default todoSlice.reducer;
To use the slice, add it to the store:
// store.js
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
const store = configureStore({
reducer: {
todos: todoReducer,
},
});
export default store;
In a React component you can dispatch the actions:
// TodoApp.js
import { useDispatch, useSelector } from 'react-redux';
import { addTodo, toggleTodo, fetchTodos } from './todoSlice';
import { useEffect } from 'react';
function TodoApp() {
const dispatch = useDispatch();
const { list, loading, error } = useSelector(state => state.todos);
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
const handleAdd = (text) => {
dispatch(addTodo(text));
};
return (
{loading && Loading…
}
{error && Error: {error}
}
{list.map(todo => (
- dispatch(toggleTodo(todo.id))}>
{todo.text} {'[' + (todo.completed ? 'done' : 'todo') + ']'}
))}
handleAdd('Learn Redux Toolkit')>}
Add sample todo
);
}
export default TodoApp;
Verifying immutability and DevTools
You can confirm that the reducer returns a new state object by checking Object.is(prevState, nextState) after dispatching an action—it should be false while the content has changed. In the browser, open the Redux DevTools extension; actions from the slice will appear with types like todos/addTodo and todos/fetchTodos/pending. The diff view will show the expected updates without any accidental mutations.
Trade‑offs and limitations
- Bundle size: Importing
createSlice pulls in Immer and other RTK utilities. For extremely small apps a hand‑written reducer may save a few hundred bytes, though tree‑shaking removes unused parts in most builds. - Immer learning curve: Developers unfamiliar with Immer might be surprised that mutating syntax works only inside
createSlicereducers; attempting the same pattern elsewhere will mutate the actual state. - Conceptual opacity: Relying heavily on
createSlicecan hide the underlying Redux mechanics (plain reducer functions, action constants). When migrating to vanilla Redux or debugging complex edge cases, a solid grasp of the basics remains valuable.
Actionable takeaway
Start new features with createSlice to reduce boilerplate and gain immutable updates by default. Keep a mental model of how Immer works, verify immutability with Object.is when needed, and periodically review the generated bundle size if you are optimizing for minimal payloads. Understanding the core Redux principles will make it easier to step outside the slice when the situation demands it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.