React 67 ⚛️ Redux Slices, Actions, and Reducers
A Redux store is a single object, and the only way to change it is to dispatch an action. The action is a plain object with a type and an optional payload. The reducer is a pure function that takes the current state and the action, and returns the new state. The classic Redux required the developer to write the action types, the action creators, and the switch statements by hand. The Redux Toolkit collapses the three into the createSlice, which generates the action creators and the reducer from a single object. This chapter covers the slice, the action, and the reducer, and the patterns that make them predictable.
Key point: A reducer is a pure function. It must not mutate its arguments, it must not perform side effects, and given the same state and action, it must return the same result. The Immer library inside the Redux Toolkit lets the developer write the mutation code, and the library produces the new state without violating the purity.
Why slices, actions, and reducers exist
The single-source-of-truth problem. The application state lives in one place. The component reads it with the selector, and the component writes it with the dispatch. The store is the one place, and the components are the views.
The predictability problem. The state change is the result of the action. The action is the record of what happened, and the reducer is the rule that determines the new state. The DevTools show the action log, and the time-travel debugging replays the actions.
The pure-function problem. The reducer is the pure function. The test is the input and the expected output, and the reducer does not depend on the outside world.
The boilerplate problem. The classic Redux required the action type constants, the action creator functions, and the switch statements. The createSlice generates the three from the one object.
The immutability problem. The reducer must return the new object. The spread operators produced the verbose and the error-prone code. The Immer produces the new state from the “mutation” code.
The composition problem. The store combines the slices. Each slice owns the part of the state, and the combineReducers (via the configureStore‘s reducer object) combines them.
The type problem. The TypeScript types are inferred from the slice. The PayloadAction<T> is the action type, and the RootState is the store type.
a. The slice
The createSlice takes the name, the initial state, and the reducers. It returns the object with the .actions and the .reducer.
The basic slice
// features/todos/todosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
items: [],
filter: 'all',
status: 'idle',
};
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action) => {
state.items.push({
id: Date.now(),
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
removeTodo: (state, action) => {
state.items = state.items.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;
The name is the prefix for the action types, the initialState is the state, and the reducers is the map of the handlers.
The generated action types
The name and the reducer key produce the action type. The addTodo reducer produces the 'todos/addTodo' type.
todos/addTodo
todos/toggleTodo
todos/removeTodo
todos/setFilter
The type is the name and the / and the reducer key.
The generated action creators
The .actions holds the action creators. The addTodo('text') returns the { type: 'todos/addTodo', payload: 'text' }.
const action = addTodo('Learn Redux');
// { type: 'todos/addTodo', payload: 'Learn Redux' }
The Immer and the state update
The state.items.push(...) looks like the mutation. The Immer wraps the state in the proxy, and the mutation produces the new state.
addTodo: (state, action) => {
state.items.push({ ... }); // The Immer produces the new state.
},
The Immer tracks the changes and produces the immutable result. The developer writes the mutation code, and the library guarantees the immutability.
The returning versus the mutating
The reducer can return the new state explicitly, or mutate the draft. The two forms are the equivalent, and the mutation is the more readable.
// The returning form
addTodo: (state, action) => {
return { ...state, items: [...state.items, action.payload] };
},
// The mutating form (the Immer)
addTodo: (state, action) => {
state.items.push(action.payload);
},
The mutating form is the recommended in the Redux Toolkit.
The slice table
| Element | Purpose |
|---|---|
name | The action type prefix |
initialState | The state |
reducers | The handlers |
.actions | The creators |
.reducer | The reducer |
.name | The slice name |
b. The action
The action is the plain object with the type and the optional payload. The PayloadAction<T> is the TypeScript type for the action with the payload.
The action shape
{
type: 'todos/addTodo',
payload: 'Learn Redux',
}
The type is the string, and the payload is the value.
The prepare callback
The prepare callback customizes the payload. The reducer receives the prepared payload.
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => {
return {
payload: {
id: Date.now(),
text,
completed: false,
},
};
},
},
},
});
The prepare runs when the action creator is called, and the reducer runs when the action is dispatched.
The nanoid for the IDs
import { nanoid } from '@reduxjs/toolkit';
prepare: (text) => {
return {
payload: {
id: nanoid(),
text,
completed: false,
},
};
},
The nanoid generates the unique ID. The Redux Toolkit includes it.
The PayloadAction type
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({ id: nanoid(), text: action.payload, completed: false });
},
removeTodo: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
},
});
The PayloadAction<string> types the payload as the string.
The action table
| Element | Purpose |
|---|---|
type | The action identifier |
payload | The data |
meta | The metadata |
error | The error flag |
prepare | The payload factory |
PayloadAction<T> | The typed action |
c. The extraReducers
The extraReducers handles the actions the slice does not create. The async thunks and the other slices’ actions are the cases.
The async thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos');
return response.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle', error: null },
reducers: { ... },
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
The createAsyncThunk creates the three actions: the pending, the fulfilled, and the rejected. The extraReducers handles the three.
The builder methods
| Method | Purpose |
|---|---|
addCase | The single action |
addMatcher | The predicate match |
addDefaultCase | The fallback |
The addMatcher
extraReducers: (builder) => {
builder.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.error = action.error.message;
}
);
},
The addMatcher matches the multiple actions with the predicate.
The extraReducers table
| Element | Purpose |
|---|---|
createAsyncThunk | The async action |
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
addCase | The single |
addMatcher | The predicate |
Complete Example Session
// ============================================
// PART 1: THE BASIC SLICE
// ============================================
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] },
reducers: {
addTodo: (state, action) => {
state.items.push({ id: Date.now(), text: action.payload });
},
removeTodo: (state, action) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
},
});
export const { addTodo, removeTodo } = todosSlice.actions;
export default todosSlice.reducer;
// ============================================
// PART 2: THE ACTION SHAPE
// ============================================
const action = addTodo('Learn Redux');
// { type: 'todos/addTodo', payload: 'Learn Redux' }
// ============================================
// PART 3: THE PREPARE CALLBACK
// ============================================
import { nanoid } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [] },
reducers: {
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => ({
payload: { id: nanoid(), text, completed: false },
}),
},
},
});
// ============================================
// PART 4: THE COMPONENT USAGE
// ============================================
import { useAppDispatch, useAppSelector } from '../app/hooks';
import { addTodo, removeTodo } from './todosSlice';
function TodoList() {
const todos = useAppSelector((state) => state.todos.items);
const dispatch = useAppDispatch();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => dispatch(removeTodo(todo.id))}>Remove</button>
</li>
))}
<button onClick={() => dispatch(addTodo('New'))}>Add</button>
</ul>
);
}
// ============================================
// PART 5: THE TYPESCRIPT SLICE
// ============================================
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodosState {
items: Todo[];
}
const initialState: TodosState = { items: [] };
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({ id: nanoid(), text: action.payload, completed: false });
},
},
});
// ============================================
// PART 6: THE ASYNC THUNK
// ============================================
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos');
return response.json();
});
// ============================================
// PART 7: THE EXTRAREDUCERS
// ============================================
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle', error: null },
reducers: { ... },
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
// ============================================
// PART 8: THE ADDMATCHER
// ============================================
extraReducers: (builder) => {
builder.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.error = action.error.message;
}
);
},
// ============================================
// PART 9: THE DISPATCH
// ============================================
const dispatch = useAppDispatch();
dispatch(fetchTodos());
dispatch(addTodo('Learn Redux'));
dispatch(removeTodo(todoId));
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. Use the createSlice for the reducer and the actions.
// 2. The name is the action type prefix.
// 3. The reducers are the mutation code with the Immer.
// 4. The .actions holds the creators.
// 5. The .reducer is the slice reducer.
// 6. Use the prepare callback for the custom payload.
// 7. Use the createAsyncThunk for the async.
// 8. Use the extraReducers for the external actions.
// 9. Use the PayloadAction<T> for the TypeScript.
// 10. Use the nanoid for the unique IDs.
The ten parts showed the basic slice, the action shape, the prepare callback, the component usage, the TypeScript slice, the async thunk, the extraReducers, the addMatcher, the dispatch, and the complete pattern.
Quick Reference
The createSlice
| Element | Purpose |
|---|---|
name | The action type prefix |
initialState | The state |
reducers | The handlers |
extraReducers | The external handlers |
.actions | The creators |
.reducer | The reducer |
The Action
| Element | Purpose |
|---|---|
type | The identifier |
payload | The data |
meta | The metadata |
error | The error flag |
The prepare
| Element | Purpose |
|---|---|
reducer | The handler |
prepare | The payload factory |
The extraReducers
| Element | Purpose |
|---|---|
addCase | The single |
addMatcher | The predicate |
addDefaultCase | The fallback |
The Async Thunk
| Element | Purpose |
|---|---|
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
Best Practices
✅ Do This:
// Use the createSlice.
const todosSlice = createSlice({ name: 'todos', ... }); // ✅
// Use the Immer mutation.
addTodo: (state, action) => {
state.items.push(action.payload);
}, // ✅
// Use the prepare for the payload.
prepare: (text) => ({ payload: { id: nanoid(), text } }), // ✅
// Use the PayloadAction type.
addTodo: (state, action: PayloadAction<string>) => { ... }, // ✅
// Use the extraReducers for the thunk.
builder.addCase(fetchTodos.fulfilled, ...), // ✅
// Use the nanoid for the IDs.
id: nanoid(), // ✅
// Export the actions and the reducer.
export const { addTodo } = todosSlice.actions;
export default todosSlice.reducer; // ✅
❌ Don’t Do This:
// Don't mutate outside the Immer.
state.items = state.items.concat(action.payload); // ⚠️ The Immer handles the push
// Don't use the Date.now() for the ID.
id: Date.now(), // ⚠️ The collision in the fast calls
// Don't forget the extraReducers for the thunk.
createAsyncThunk(...); // ❌ The state is not updated
// Don't use the classic action types.
const ADD_TODO = 'ADD_TODO'; // ❌ The slice generates them
// Don't write the switch statement.
switch (action.type) { case 'ADD_TODO': ... } // ❌ The slice generates it
// Don't return undefined from the reducer.
addTodo: (state, action) => {
return undefined; // ❌ The state is lost
}, // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The state not updated | The Immer not used | Mutate the state |
| The action type wrong | The wrong name | Check the slice name |
| The payload wrong | No prepare | Add the prepare |
| The thunk state missing | No extraReducers | Add the cases |
| The ID collision | The Date.now() | Use nanoid() |
| The type errors | No PayloadAction | Add the type |
| The reducer lost | The undefined return | Return the state or mutate |
Real-World Examples
1. The Basic Slice
const todosSlice = createSlice({ name: 'todos', initialState, reducers: { ... } });
2. The Immer Mutation
addTodo: (state, action) => { state.items.push(action.payload); }
3. The Prepare
prepare: (text) => ({ payload: { id: nanoid(), text } })
4. The PayloadAction
addTodo: (state, action: PayloadAction<string>) => { ... }
5. The Export
export const { addTodo } = todosSlice.actions;
export default todosSlice.reducer;
6. The Async Thunk
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => { ... });
7. The extraReducers
builder.addCase(fetchTodos.fulfilled, (state, action) => { state.items = action.payload; })
8. The Dispatch
dispatch(addTodo('Learn Redux'));
9. The Selector
const todos = useAppSelector((state) => state.todos.items);
10. The Complete Pattern
const todosSlice = createSlice({ name: 'todos', initialState, reducers: { ... } });
Visual
The Slice
┌────────────────────────────────────────────────────────────┐
│ THE SLICE │
│ │
│ createSlice({ │
│ name: 'todos', ← the prefix │
│ initialState: { ... }, ← the state │
│ reducers: { ← the handlers │
│ addTodo: (state, action) => { │
│ state.items.push(action.payload); │
│ }, │
│ }, │
│ }); │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ .actions → { addTodo, removeTodo, ... } │ │
│ │ .reducer → the reducer function │ │
│ │ .name → 'todos' │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Action
┌────────────────────────────────────────────────────────────┐
│ THE ACTION │
│ │
│ addTodo('Learn Redux') │
│ ↓ │
│ { │
│ type: 'todos/addTodo', │
│ payload: 'Learn Redux', │
│ } │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The type is the slice name and the reducer key. │ │
│ │ The payload is the argument. │ │
│ │ The dispatch sends the action to the reducer. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Reducer
┌────────────────────────────────────────────────────────────┐
│ THE REDUCER │
│ │
│ (state, action) => { │
│ state.items.push(action.payload); │
│ } │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The Immer wraps the state in the proxy. │ │
│ │ The push is the "mutation". │ │
│ │ The Immer produces the new state. │ │
│ │ │ │
│ │ The state is the immutable, the code is the │ │
│ │ readable. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The extraReducers
┌────────────────────────────────────────────────────────────┐
│ THE extraReducers │
│ │
│ extraReducers: (builder) => { │
│ builder │
│ .addCase(fetchTodos.pending, (state) => { │
│ state.status = 'loading'; │
│ }) │
│ .addCase(fetchTodos.fulfilled, (state, action) => { │
│ state.status = 'succeeded'; │
│ state.items = action.payload; │
│ }) │
│ .addCase(fetchTodos.rejected, (state, action) => { │
│ state.status = 'failed'; │
│ state.error = action.error.message; │
│ }); │
│ }, │
│ │
│ The createAsyncThunk produces the three actions. │
│ The extraReducers handles them. │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
createSlice | Create the reducer and the actions |
name | The action type prefix |
initialState | The state |
reducers | The handlers |
extraReducers | The external handlers |
.actions | The creators |
.reducer | The reducer |
| The Immer | The immutable updates |
PayloadAction<T> | The typed action |
createAsyncThunk | The async action |
Key takeaways:
- The
createSlicegenerates the reducer and the action creators. Thenameis the prefix, theinitialStateis the state, and thereducersis the map of the handlers. The.actionsand the.reducerare the exports. - The action is the plain object with the
typeand thepayload. Thetypeis thenameand the/and the reducer key. Thepayloadis the value. - The Immer lets the “mutation” code produce the immutable state. The
state.items.push(...)looks like the mutation, and the library produces the new state. The developer writes the readable code, and the store is the immutable. - The
preparecallback customizes the payload. Theprepareruns when the action creator is called, and thereducerruns when the action is dispatched. Thenanoidgenerates the unique IDs. - The
extraReducershandles the external actions. ThecreateAsyncThunkproduces thepending, thefulfilled, and therejected. Thebuilder.addCasehandles the three. - The
PayloadAction<T>types the payload. ThePayloadAction<string>types the payload as the string. The TypeScript infers the rest from the slice. - The reducer must be pure. The Immer lets the mutation code, but the reducer must not perform the side effects. The side effects belong to the thunk or the listener middleware.
- The action creators are the API. The
addTodo('text')is the call, and thedispatchsends the action. The component does not construct the action object by hand.
Remember: The slices, the actions, and the reducers are the Redux Toolkit’s answer to the classic boilerplate. The createSlice generates the reducer and the action creators from the one object, and the Immer produces the immutable state from the readable mutation code. The result is the store where the actions are the records, the reducers are the rules, and the slices are the domains.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!