๐ฐ Creating Slices with createSlice
A "slice" is one department of your Redux store โ a slab of state plus the reducers and actions that manage it. createSlice is the API that builds all three at once. Master it and you've mastered the day-to-day of Redux Toolkit.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Describe what a slice is and how slices compose into the root state
- Write a slice with
createSliceand explain every field it returns - Work with action payloads and use a prepare callback for multi-argument or generated data
- Write immutable updates โ including nested and normalized data โ using Immer
- Respond to outside actions with
extraReducersand co-locate memoized selectors
Estimated Time: 35โ45 minutes โข Difficulty: Intermediate
Hands-on: Build a complete todo slice with payloads, a prepare callback, and selectors.
In This Lesson
What Is a Slice?
A slice represents a portion of your Redux state along with the reducers and actions that manipulate it. Rather than one giant reducer for the whole app, you split state by feature โ a users slice, a posts slice, a cart slice โ and each owns its own logic.
The createSlice function brings together three things: a name (the prefix for action types), an initial state, and a set of reducer functions. From those it automatically generates the matching action creators and action types.
๐ก The department analogy. A slice is like a department in a company. Each department manages its own area (state), follows standard procedures for requests (reducers), keeps its own records (data), and has a clear name on the door (the slice name). Requests (actions) are routed to the right department by their type prefix โ cart/addItem goes to the cart department, not the users one.
The createSlice API
createSlice takes a single configuration object:
import { createSlice } from '@reduxjs/toolkit';
const mySlice = createSlice({
name: 'feature', // prefix for generated action types
initialState: { /* ... */ }, // the slice's starting state
reducers: { // your case reducers โ generate actions
// reducer functions here
},
extraReducers: (builder) => { // respond to actions from elsewhere
// builder.addCase(...) etc.
},
});
// Destructure and export the generated action creators
export const { actionOne, actionTwo } = mySlice.actions;
// Export the reducer for the whole slice
export default mySlice.reducer;
Key parameters
- name (required): a string used as the prefix for every generated action type.
- initialState (required): the starting value for this slice of state.
- reducers (required): an object of reducer functions; each key becomes an action name.
- extraReducers (optional): reducers that respond to actions defined outside this slice.
What it returns
The call returns an object with a name, a reducer function for the whole slice, an actions object of generated creators, and caseReducers (the raw functions, rarely used directly).
๐ Action type naming
Types are generated as slice.name + '/' + reducerKey. A slice named 'todos' with a reducer addTodo produces the type 'todos/addTodo'. This automatic namespacing is what keeps action types from colliding across slices โ you never write a type constant by hand again.
Building a Basic Slice
Here's a complete counter slice โ state, reducers, exported actions, and reducer:
// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = { value: 0, status: 'idle' };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// Immer lets us "mutate" the draft safely.
increment: (state) => { state.value += 1; },
decrement: (state) => { state.value -= 1; },
incrementByAmount: (state, action) => { state.value += action.payload; },
reset: (state) => { state.value = 0; },
},
});
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
export default counterSlice.reducer;
Mount its reducer in the store:
// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
export const store = configureStore({
reducer: { counter: counterReducer },
});
And use the generated actions in a component with the typed React-Redux hooks:
// features/counter/Counter.jsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';
export function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(decrement())}>โ</button>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(incrementByAmount(5))}>Add 5</button>
</div>
);
}
Action Payloads & prepare
Most actions carry data. Whatever you pass to a generated action creator lands on action.payload:
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// Simple payload โ dispatch(addTodo('Buy milk'))
addTodo: (state, action) => {
state.push({ id: Date.now(), text: action.payload, completed: false });
},
// Object payload โ dispatch(updateTodo({ id: 123, changes: {...} }))
updateTodo: (state, action) => {
const { id, changes } = action.payload;
const todo = state.find((t) => t.id === id);
if (todo) Object.assign(todo, changes);
},
},
});
export const { addTodo, updateTodo } = todosSlice.actions;
The prepare callback
An action creator normally puts its single argument straight into payload. When you need to accept multiple arguments, generate a value like an ID or timestamp, or attach metadata, supply an object with both a prepare function and a reducer:
import { createSlice, nanoid } from '@reduxjs/toolkit';
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
createPost: {
// prepare shapes the payload before it reaches the reducer
prepare(title, content, authorId) {
return {
payload: {
id: nanoid(),
title,
content,
authorId,
date: new Date().toISOString(),
reactions: { thumbsUp: 0, heart: 0, rocket: 0 },
},
};
},
reducer(state, action) {
state.push(action.payload);
},
},
deletePost(state, action) {
return state.filter((post) => post.id !== action.payload);
},
},
});
export const { createPost, deletePost } = postsSlice.actions;
// Clean multi-argument call site:
// dispatch(createPost('Redux Toolkit', 'Awesome library!', 'user123'));
๐ก When to reach for prepare
Use a prepare callback whenever you need to accept multiple parameters, generate random values (IDs, timestamps), transform inputs before storing them, or attach meta (for analytics or middleware). It keeps the call site clean while hiding the payload-construction complexity inside the slice.
Immutable Updates with Immer
Redux state must be updated immutably. In classic Redux that means endless spreading and mapping. Because createSlice runs your reducers through Immer, you can write code that looks like direct mutation and still get a new immutable state:
// โ Without Immer โ verbose spreading
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'todos/toggleTodo':
return state.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
default:
return state;
}
};
// โ
With Immer via createSlice โ reads like plain JS
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
toggleTodo: (state, action) => {
const todo = state.find((t) => t.id === action.payload);
todo.completed = !todo.completed;
},
},
});
Common Immer patterns
Immer shines with nested and normalized state, where hand-copying would be painful:
const appSlice = createSlice({
name: 'app',
initialState: {
users: [],
settings: { theme: 'light', notifications: { push: false } },
posts: { byId: {}, allIds: [] },
},
reducers: {
addUser: (state, action) => {
state.users.push(action.payload); // add to array
},
removeUser: (state, action) => {
const i = state.users.findIndex((u) => u.id === action.payload);
if (i !== -1) state.users.splice(i, 1); // remove from array
},
togglePush: (state) => {
state.settings.notifications.push = !state.settings.notifications.push; // deep nested
},
addPost: (state, action) => {
const { id } = action.payload; // normalized update
state.posts.byId[id] = action.payload;
state.posts.allIds.push(id);
},
},
});
โ ๏ธ The either/or rule. A reducer can mutate the draft or return a new value โ not both. InremoveUserabove we mutate; in a filter-based delete you'dreturnthe filtered array. Doing both in one reducer throws an Immer error.
extraReducers
The reducers field generates actions owned by the slice. The extraReducers field lets a slice respond to actions defined elsewhere โ most commonly the pending/fulfilled/rejected actions from createAsyncThunk, but also actions from other slices. Use the "builder callback" form:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await fetch('https://api.example.com/users');
return response.json();
});
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle', error: null },
reducers: {
userAdded: (state, action) => { state.entities.push(action.payload); },
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = 'loading';
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = 'idle';
state.entities = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = 'idle';
state.error = action.error.message;
})
// React to an action owned by a DIFFERENT slice:
.addCase('auth/logout', (state) => {
state.entities = []; // clear users when the user logs out
})
// Match a whole family of actions by pattern:
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state) => { state.lastFetchSuccess = new Date().toISOString(); }
);
},
});
The builder exposes three methods:
addCase(actionCreator, reducer)โ handle one specific action type.addMatcher(matcher, reducer)โ handle any action satisfying a predicate.addDefaultCase(reducer)โ handle anything not already matched.
โ Why this matters
extraReducers is how slices cooperate without importing each other's internals. An auth slice can clear its data on auth/logout, a users slice can wipe its cache on the same event, and neither needs to know about the other's reducers โ they just listen for the action.
Co-locating Selectors
Selectors are functions that read specific data out of the store. Defining them next to the slice keeps components ignorant of the state's exact shape โ a big win for maintainability. Simple selectors are one-liners; derived data uses createSelector for memoization:
// features/posts/postsSlice.js
import { createSlice, createSelector } from '@reduxjs/toolkit';
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
filters: { status: 'all', category: null },
},
reducers: {
setStatusFilter: (state, action) => { state.filters.status = action.payload; },
setCategoryFilter: (state, action) => { state.filters.category = action.payload; },
},
});
export const { setStatusFilter, setCategoryFilter } = postsSlice.actions;
export default postsSlice.reducer;
// Simple selectors
export const selectAllPosts = (state) => state.posts.items;
export const selectStatusFilter = (state) => state.posts.filters.status;
export const selectCategoryFilter = (state) => state.posts.filters.category;
// Memoized selector โ only recomputes when its inputs change
export const selectFilteredPosts = createSelector(
[selectAllPosts, selectStatusFilter, selectCategoryFilter],
(posts, status, category) =>
posts.filter((post) => {
const statusMatch = status === 'all' || post.status === status;
const categoryMatch = !category || post.category === category;
return statusMatch && categoryMatch;
})
);
// Using them in a component
import { useSelector } from 'react-redux';
import { selectFilteredPosts } from './postsSlice';
function PostsList() {
const posts = useSelector(selectFilteredPosts);
return posts.map((post) => <PostItem key={post.id} post={post} />);
}
๐ Why co-locate?
Encapsulation โ components don't know the state shape. Reusability โ one selector, many components. Maintainability โ reshape state (say, array โ normalized byId) and you only touch the selectors. Performance โ memoized selectors skip recomputation when inputs are unchanged.
Hands-on Exercise
๐๏ธ Build a Complete Todo Slice
Objective: Combine payloads, a prepare callback, Immer updates, and a selector in one real slice.
Requirements:
- Initial state: a
todosarray and afilterstring ('all'). addTodoโ use a prepare callback to generate anid(withnanoid), acreatedAttimestamp, andcompleted: false.toggleTodoandremoveTodoโ Immer updates by id.setFilterโ store the current filter.- A memoized
selectVisibleTodosselector that applies the filter.
๐ก Hint
Import nanoid from @reduxjs/toolkit. The prepare callback must return { payload: {...} }. For the selector, feed selectAllTodos and selectFilter into createSelector and switch on the filter value ('all' | 'active' | 'completed').
โ Solution
// features/todos/todosSlice.js
import { createSlice, createSelector, nanoid } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { todos: [], filter: 'all' },
reducers: {
addTodo: {
prepare(text) {
return {
payload: {
id: nanoid(),
text,
completed: false,
createdAt: new Date().toISOString(),
},
};
},
reducer(state, action) {
state.todos.push(action.payload);
},
},
toggleTodo: (state, action) => {
const todo = state.todos.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
removeTodo: (state, action) => {
state.todos = state.todos.filter((t) => t.id !== action.payload);
},
setFilter: (state, action) => {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions;
export default todosSlice.reducer;
export const selectAllTodos = (state) => state.todos.todos;
export const selectFilter = (state) => state.todos.filter;
export const selectVisibleTodos = createSelector(
[selectAllTodos, selectFilter],
(todos, filter) => {
if (filter === 'active') return todos.filter((t) => !t.completed);
if (filter === 'completed') return todos.filter((t) => t.completed);
return todos;
}
);
๐ฏ Quick Quiz
Question 1: A slice is created with name: 'cart' and a reducer key addItem. What action type does RTK generate?
Question 2: You need an action creator that accepts three arguments and generates a unique id. Which feature do you use?
Question 3: Your users slice should reset itself when the auth slice dispatches auth/logout. Where do you handle that?
Summary & Quiz
๐ Key Takeaways
- A slice bundles a piece of state with its reducers and actions; slices compose into the root store.
createSlicegenerates action types, action creators, and a reducer from one config object.- Data arrives on
action.payload; a prepare callback handles multi-argument or generated payloads. - Immer lets you write "mutating" reducers โ but never mutate and return in the same one.
extraReducersresponds to outside actions; co-located selectors keep components decoupled from state shape.
๐ Further Reading
- createSlice API Documentation
- Redux Essentials, Part 2: App Structure
- Redux Style Guide
- Reselect โ Memoized Selectors
๐ What's Next?
You can now model synchronous state cleanly. But most real features talk to a server. Next, Asynchronous Actions with createAsyncThunk shows how to fetch, handle loading states, and manage errors โ the async half of the story that extraReducers was quietly preparing you for.
๐ Slice mastered!
You've got the core of Redux Toolkit. Let's make it talk to APIs.