Redux Toolkit 03 ⚛️ Creating Slices — Combining state, actions, and reducers
A slice is the fundamental unit of Redux Toolkit logic. It bundles three things that classic Redux kept in separate files: the initial state, the reducer functions that modify that state, and the action creators that trigger those reducers. The createSlice function accepts a name, an initial state, and a set of reducer functions, and returns a slice object containing the reducer, the action creators, and the action types—all generated automatically from the reducer names . This co-location is the core of Redux Toolkit’s ergonomics: one file defines everything a feature needs, and the store imports only the reducer.
The previous chapters created the store and connected it to React. This chapter fills the store with logic. Every slice created with createSlice is added to the reducer object in configureStore, and its state becomes a branch of the root state tree. The state shape, the action types, and the TypeScript inference all derive from the slice definition.
Key point: createSlice uses Immer internally, which means reducers can be written with mutating syntax—state.value += 1—and the library produces an immutable update behind the scenes. This is not a license to mutate state outside reducers; it is a convenience that applies only inside the reducers object, where Immer wraps the state in a draft that tracks mutations and produces a new immutable value.
Why slices replace the classic Redux triad
The action-type boilerplate problem.
Classic Redux required a separate constants file for action types, a separate file for action creators, and a separate file for the reducer. Each action required three definitions: const INCREMENT = 'counter/increment', const increment = () => ({ type: INCREMENT }), and a switch case in the reducer. createSlice generates all three from a single reducer function named increment. The action type becomes 'counter/increment' automatically, and the action creator is exported as slice.actions.increment .
The immutability problem.
Classic Redux reducers had to return a new state object manually: return { ...state, value: state.value + 1 }. For nested data, this required spreads at every level, and a missing spread silently mutated the existing state. createSlice uses Immer, so the reducer can be written as state.value += 1. Immer tracks the mutation on a draft object and produces a new immutable state with the correct references. The code is shorter and the immutability is guaranteed by the library rather than by developer discipline .
The TypeScript inference problem.
The PayloadAction<T> type describes an action with a typed payload. createSlice infers the action creators’ types from the reducer signatures, so slice.actions.incrementByAmount(5) is type-checked against the payload type declared in the reducer. The slice’s state type is defined once in the initialState, and the RootState type from the store includes it automatically when the reducer is added to configureStore .
a. The anatomy of a slice
A slice is defined in a feature folder, typically src/features/<name>/<name>Slice.ts. The createSlice call accepts a configuration object with three required fields: name, initialState, and reducers.
// src/features/counter/counterSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
// Define the state shape as an interface
interface CounterState {
value: number
step: number
}
// The initial state must match the interface
const initialState: CounterState = {
value: 0,
step: 1,
}
const counterSlice = createSlice({
name: 'counter', // Prefix for action types: 'counter/increment'
initialState, // The state branch for this slice
reducers: {
// Each function becomes an action creator and a reducer case
increment: (state) => {
state.value += state.step // Immer draft; mutation is safe
},
decrement: (state) => {
state.value -= state.step
},
// PayloadAction<T> types the action creator's argument
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
setStep: (state, action: PayloadAction<number>) => {
state.step = action.payload
},
reset: (state) => {
state.value = 0
state.step = 1
},
},
})
// Export the action creators
export const { increment, decrement, incrementByAmount, setStep, reset } =
counterSlice.actions
// Export the reducer for the store
export default counterSlice.reducer
The name field determines the action type prefix. The increment reducer produces the action type 'counter/increment'. The action creator increment() returns { type: 'counter/increment' }. The reducer function is the case handler for that type. All of this is generated from the single function name .
The PayloadAction<number> type on incrementByAmount tells TypeScript that the action creator accepts a number. Calling incrementByAmount('5') produces a type error. The action.payload inside the reducer is typed as number, so no cast is needed.
The default export is the reducer function. It is imported into the store and added to the reducer object under the slice’s name.
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer, // state.counter is CounterState
},
})
The key in the reducer object—counter—becomes the branch name in the root state. A selector reads it as state.counter.value. The key does not have to match the slice’s name, but keeping them consistent avoids confusion .
b. Immer’s mutation syntax and its boundaries
Inside the reducers object, the state argument is an Immer draft. It looks like the plain state object, but it records mutations and produces an immutable copy when the reducer returns.
reducers: {
// Immer draft: direct assignment is safe
increment: (state) => {
state.value += 1 // Produces a new state object
},
// Returning a new value replaces the state
setValue: (state, action: PayloadAction<number>) => {
return { ...state, value: action.payload }
},
// Arrays can be mutated with push/filter on the draft
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({ id: Date.now(), text: action.payload, done: false })
},
}
The mutation syntax is safe only inside the reducers object. Outside the reducer, state is immutable, and mutating it directly causes bugs that the development-time middleware will warn about. The boundary is the reducer function: inside it, Immer handles immutability; outside it, the developer must use dispatch with action creators.
A subtle rule: a reducer must either mutate the draft or return a new value, not both. Returning undefined after mutating the draft is correct, because the mutation is the update. Returning a new object after mutating the draft produces an error because Immer cannot reconcile the two.
c. Adding slices to the store and inferring types
Each slice is added to the store’s reducer object. The RootState type, inferred from the store, automatically includes every slice’s state. The useAppSelector hook from the previous chapter then knows the full state shape.
// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '../features/counter/counterSlice'
import todosReducer from '../features/todos/todosSlice'
import userReducer from '../features/user/userSlice'
export const store = configureStore({
reducer: {
counter: counterReducer,
todos: todosReducer,
user: userReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
The root state now has three branches: counter, todos, and user. A selector reads state.counter.value, state.todos.items, or state.user.name. The types are inferred; no manual root state interface is needed.
When a new slice is added, it is imported and added to the object. The RootState type updates automatically because it is derived from store.getState. Existing selectors that reference other branches continue to work, and the new branch is available to selectors that need it.
Complete Example Session
// ============================================
// PART 1: DEFINE THE STATE INTERFACE
// ============================================
// src/features/todos/todosSlice.ts
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface Todo {
id: number
text: string
done: boolean
}
interface TodosState {
items: Todo[]
filter: 'all' | 'active' | 'completed'
}
// ============================================
// PART 2: DEFINE THE INITIAL STATE
// ============================================
const initialState: TodosState = {
items: [],
filter: 'all',
}
// ============================================
// PART 3: CREATE THE SLICE
// ============================================
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// ============================================
// PART 4: ARRAY MUTATION WITH IMMER
// ============================================
// Immer allows push on the draft
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({
id: Date.now(),
text: action.payload,
done: false,
})
},
// ============================================
// PART 5: UPDATING AN ARRAY ELEMENT
// ============================================
// find returns the draft element; mutating it is safe
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.items.find((item) => item.id === action.payload)
if (todo) {
todo.done = !todo.done
}
},
// ============================================
// PART 6: REMOVING AN ARRAY ELEMENT
// ============================================
// splice mutates the draft array
removeTodo: (state, action: PayloadAction<number>) => {
const index = state.items.findIndex((item) => item.id === action.payload)
if (index !== -1) {
state.items.splice(index, 1)
}
},
// ============================================
// PART 7: UPDATING A SIMPLE VALUE
// ============================================
setFilter: (state, action: PayloadAction<TodosState['filter']>) => {
state.filter = action.payload
},
},
})
// ============================================
// PART 8: EXPORT ACTIONS AND REDUCER
// ============================================
export const { addTodo, toggleTodo, removeTodo, setFilter } = todosSlice.actions
export default todosSlice.reducer
// ============================================
// PART 9: ADD TO THE STORE
// ============================================
// src/app/store.ts
// import todosReducer from '../features/todos/todosSlice'
//
// export const store = configureStore({
// reducer: {
// todos: todosReducer,
// },
// })
// ============================================
// PART 10: CONSUME IN A COMPONENT
// ============================================
// function TodoList() {
// const items = useAppSelector((state) => state.todos.items)
// const filter = useAppSelector((state) => state.todos.filter)
// const dispatch = useAppDispatch()
//
// return (
// <ul>
// {items.map((todo) => (
// <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
// {todo.text}
// </li>
// ))}
// </ul>
// )
// }
The ten parts demonstrate the complete slice definition: the state interface, the initial state, the reducer functions with Immer mutations, the exported action creators, the reducer export, the store integration, and the component consumption. The Immer syntax in Parts 4–6 uses push, direct property assignment, and splice—all mutations that would be forbidden outside a reducer but are safe inside the Immer draft.
Quick Reference
createSlice Configuration
| Field | Required | Purpose |
|---|---|---|
name | Yes | Prefix for action types |
initialState | Yes | State branch for the slice |
reducers | Yes | Object of reducer functions |
extraReducers | No | Handlers for external actions |
Generated Artifacts
| Artifact | Access | Example |
|---|---|---|
| Action creators | slice.actions.<name> | todosSlice.actions.addTodo |
| Action type | '<name>/<reducerName>' | 'todos/addTodo' |
| Reducer | slice.reducer | Default export |
| Slice name | slice.name | 'todos' |
Immer Rules Inside Reducers
| Operation | Allowed |
|---|---|
state.value = x | Yes |
state.items.push(x) | Yes |
state.items.splice(i, 1) | Yes |
return { ...state, value: x } | Yes (instead of mutation) |
| Mutate and return a new value | No |
| Mutate state outside reducer | No |
Best Practices
✅ Do This:
// Define the state interface separately
interface CounterState { value: number } // ✅
// Use PayloadAction for typed payloads
incrementBy: (state, action: PayloadAction<number>) => ... // ✅
// Mutate the Immer draft directly
state.value += 1 // ✅
// Export action creators individually
export const { increment, decrement } = slice.actions // ✅
// Export the reducer as default
export default slice.reducer // ✅
// Add the reducer to configureStore
reducer: { counter: counterReducer } // ✅
❌ Don’t Do This:
// Mutate state outside the reducer
store.getState().counter.value = 5 // ❌
// Mutate and return a new value in the same reducer
state.value = 5; return { ...state, other: 1 } // ❌
// Use untyped action.payload
(state, action) => state.value += action.payload // ❌ (implicit any)
// Forget to add the reducer to the store
// Slice exists but state.counter is undefined // ❌
// Export the slice object instead of its reducer
export default counterSlice // Wrong for store import // ❌
// Change the store key without updating selectors
reducer: { count: counterReducer } // state.counter is undefined // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
state.counter is undefined | Reducer not added to store | Import and add to reducer object |
| Action type mismatch | name field changed | Action types are derived from name |
| Immer error “mutate and return” | Reducer both mutates and returns | Choose one pattern |
action.payload is any | Missing PayloadAction<T> | Add the type parameter |
| State not updating in UI | Mutating state outside reducer | Dispatch action creators |
| Slice imported but not used | Reducer not registered | Add to configureStore |
Real-World Examples
1. Minimal counter slice
const slice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: { increment: (state) => { state.value += 1 } },
})
export const { increment } = slice.actions
export default slice.reducer
2. Slice with typed payload
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
3. Array push with Immer
addTodo: (state, action: PayloadAction<string>) => {
state.items.push({ id: Date.now(), text: action.payload })
}
4. Array filter with return
clearCompleted: (state) => {
return { ...state, items: state.items.filter((t) => !t.done) }
}
5. Toggle nested object
toggle: (state, action: PayloadAction<number>) => {
const item = state.items.find((i) => i.id === action.payload)
if (item) item.done = !item.done
}
6. Reset to initial state
reset: () => initialState
7. Export actions
export const { addTodo, toggleTodo } = todosSlice.actions
8. Add to store
reducer: { todos: todosReducer }
9. Selector for slice state
const items = useAppSelector((state) => state.todos.items)
10. Multiple slices in store
reducer: { counter: counterReducer, todos: todosReducer, user: userReducer }
Visual
The Slice Anatomy
┌──────────────────────────────────────────────────────────────┐
│ createSlice BUNDLES STATE, REDUCERS, AND ACTIONS │
│ │
│ createSlice({ │
│ name: 'counter', │
│ initialState: { value: 0 }, │
│ reducers: { │
│ increment: (state) => { state.value += 1 }, │
│ decrement: (state) => { state.value -= 1 }, │
│ }, │
│ }) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Returned Slice Object │ │
│ │ │ │
│ │ .name → 'counter' │ │
│ │ .reducer → function(state, action) │ │
│ │ .actions → { increment, decrement } │ │
│ │ Action types: 'counter/increment', │ │
│ │ 'counter/decrement' │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ All three are derived from the reducer names. │
│ No manual action type constants. │
└──────────────────────────────────────────────────────────────┘
Immer Draft Workflow
┌──────────────────────────────────────────────────────────────┐
│ IMMER TURNS MUTATIONS INTO IMMUTABLE UPDATES │
│ │
│ Current state (immutable): │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { value: 0, step: 1 } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Immer creates a draft: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ draft = { value: 0, step: 1 } (proxy) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Reducer mutates the draft: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ draft.value += 1 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Immer produces a new immutable state: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ { value: 1, step: 1 } ← new reference │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ The original state is not modified. │
└──────────────────────────────────────────────────────────────┘
Slice to Store to Component
┌──────────────────────────────────────────────────────────────┐
│ FROM SLICE DEFINITION TO COMPONENT SUBSCRIPTION │
│ │
│ counterSlice.ts │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ createSlice({ name: 'counter', ... }) │ │
│ │ export default counterSlice.reducer │ │
│ │ export const { increment } = counterSlice.actions │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ store.ts │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ configureStore({ reducer: { counter: counterReducer }})│ │
│ │ → RootState includes { counter: CounterState } │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ▼ │
│ Component.tsx │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const value = useAppSelector((s) => s.counter.value) │ │
│ │ const dispatch = useAppDispatch() │ │
│ │ dispatch(increment()) │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | createSlice({ name, initialState, reducers }) |
| Action type format | '<name>/<reducerName>' |
| Action creators | slice.actions.<reducerName> |
| Reducer export | slice.reducer |
| State mutation | Allowed inside reducers via Immer draft |
| Payload typing | PayloadAction<T> |
| Store integration | Add reducer to configureStore reducer object |
| Immer limitation | Mutate or return, not both |
| RootState update | Automatic when reducer is added |
Key takeaways:
- A slice bundles state, reducers, and action creators. The
namefield prefixes the action types, theinitialStatedefines the state branch, and thereducersobject generates both the reducer cases and the action creators . - Immer allows mutating syntax inside reducers.
state.value += 1andstate.items.push(...)are safe because Immer wraps the state in a draft and produces an immutable copy. This convenience applies only inside thereducersobject . PayloadAction<T>types the action creator’s argument. The payload type is declared in the reducer signature, and TypeScript enforces it at the call site.action.payloadinside the reducer is typed accordingly.- A reducer must mutate or return, not both. Returning
undefinedafter mutating the draft is correct. Returning a new value is also correct. Doing both produces an Immer error. - The slice reducer is added to the store under a key. The key becomes the state branch name. A selector reads
state.<key>.<field>. TheRootStatetype updates automatically when the reducer is added toconfigureStore. - Action creators are exported individually.
export const { increment, decrement } = slice.actionsgives components named functions to dispatch. The default export is the reducer, imported by the store. - The slice is the unit of feature logic. One file defines the state shape, the update logic, and the action creators for a feature. The store composes the slices, and the components dispatch the actions and select the state.
Remember: A slice is a self-contained description of a piece of state and the operations that modify it. createSlice generates the action types, the action creators, and the reducer from a single object of reducer functions. Immer makes the reducer code look like mutation while producing immutable updates. The slice’s reducer is added to configureStore, and its state becomes a branch of RootState. The action creators are exported and dispatched from components. Get the slice shape right, and the store composition and component subscriptions follow.
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!