This episode teaches createSlice: defining name, initialState, and reducers in one place, with automatically generated action creators and action types. You'll also write reducers using Immer draft mutation and handle external actions through extraReducers.

In episode 3 you already had a live store, but it was empty. Now we fill that store with a slice — a piece of state along with its reducer and actions. createSlice is the star of Redux Toolkit: one function that removes nearly all classic Redux boilerplate.
Episode 4 covers three things: how to define a slice with name, initialState, and reducers; how Immer makes writing reducers feel like direct mutation; and how extraReducers handles actions coming from outside the slice — including the async actions we'll learn about in episode 6.
createSlice accepts a single configuration object with three main properties:
import { createSlice } from "@reduxjs/toolkit"
const counterSlice = createSlice({
name: "counter",
initialState: { count: 0 },
reducers: {
increment(state) {
state.count += 1
},
decrement(state) {
state.count -= 1
},
setTo(state, action) {
state.count = action.payload
},
},
})
export const { increment, decrement, setTo } = counterSlice.actions
export default counterSlice.reducername becomes the prefix of every action type, e.g. counter/increment.initialState is the initial shape of the slice state.reducers holds the functions that handle state changes.The biggest advantage of createSlice: every key in reducers automatically generates an action creator and an action type.
console.log(increment())
console.log(setTo(5))increment() produces { type: "counter/increment" }, and setTo(5) produces { type: "counter/setTo", payload: 5 }. You no longer need to write action constants and action creators by hand like in the classic Redux era. The whole slice is registered in the store:
import { configureStore } from "@reduxjs/toolkit"
import counterReducer from "../features/counter/counterSlice"
export const store = configureStore({
reducer: {
counter: counterReducer,
},
})counter: counterReducer mounts the counter slice reducer into the state.counter slot of the store. This slot name will be used by selectors in episode 5.
Notice that inside the reducer we write state.count += 1 — it looks like mutation, and that's intentional. createSlice wraps every reducer with Immer, so the state the function receives is a temporary draft.
reducers: {
increment(state) {
state.count += 1
},
}Immer records all changes to the draft, then produces a fully immutable new state object behind the scenes. Redux DevTools will display the change as a clear diff — without Immer, you'd have to write { count: state.count + 1 } by hand.
Warning
Never both return a new value AND mutate the draft in the same reducer. Pick one pattern: mutate the draft only, or return a new object only. Combining both produces undefined behavior.
There are cases where draft mutation isn't practical: replacing the entire state or performing heavy data transforms. For these, returning a new object is still allowed:
reducers: {
reset(state) {
return { count: 0 }
},
}return { count: 0 } replaces the entire slice state at once — without spreading the old draft. Both styles are valid; use whichever reads more clearly.
extraReducers is used to respond to actions that aren't part of this slice. Typical examples are actions from createAsyncThunk (episode 6) or actions from another slice:
const cartSlice = createSlice({
name: "cart",
initialState: { items: [], lastAction: null },
reducers: {},
extraReducers: (builder) => {
builder.addCase("user/logout", (state) => {
state.items = []
})
},
})builder.addCase registers one handler per action type. state.items = [] empties the cart every time a user logs out — cross-feature synchronization without importing each other's reducers.
extraReducers supports three builder forms:
addCase(actionCreator, reducer): for a specific action.addMatcher(predicate, reducer): for actions matching a predicate.addDefaultCase(reducer): fallback for unrecognized actions.extraReducers: (builder) => {
builder
.addMatcher((action) => action.type.endsWith("/pending"), (state) => {
state.loading = true
})
.addDefaultCase((state) => state)
}addMatcher is very useful for catching action patterns, for example every action ending in pending. This pattern is widely used when working with thunk lifecycles.
Episode 4 introduces the heart of modern Redux state writing: one createSlice produces the reducer, action creators, and action types all at once. Immer makes reducers feel like direct mutation, and extraReducers opens the door to cross-slice collaboration.
Key takeaways:
createSlice accepts name, initialState, and reducers.extraReducers with builder.addCase for external actions.addMatcher and addDefaultCase for broader action patterns.In the next episode, episode 5, you'll read that state from components with selectors and hooks — using useSelector and useDispatch, understanding selectors as pure functions, and keeping performance in check with shallowEqual when selecting complex data.