Redux Toolkit 08 ⚛️ Action Reducers — Handling external actions with extraReducers
The reducers object in createSlice defines the actions that the slice owns. Every reducer in that object generates an action creator, and the action type is prefixed with the slice’s name. That is the correct place for actions that belong to the feature. But a slice also needs to respond to actions it does not own: the pending, fulfilled, and rejected actions from a createAsyncThunk, the shared actions from a createAction module, and the actions from other slices in a cross-cutting workflow. The extraReducers field is the mechanism for handling those external actions without generating new action creators.
The previous chapter covered createAction and the shared actions that are defined outside a slice. This chapter covers the extraReducers builder that handles them, the difference between the object form and the builder callback form, the state-mutation rules that apply inside the builder, and the pattern for handling the three lifecycle actions of an async thunk.
Key point: extraReducers does not generate action creators. It only handles actions that are defined elsewhere. The builder callback form is the recommended syntax because it provides TypeScript inference, handles the Immer draft correctly, and supports addCase, addMatcher, and addDefaultCase. The reducer inside each addCase receives the Immer draft, so it can mutate the state directly, just like a reducer in the reducers object.
Why extraReducers is separate from reducers
The action-ownership problem.
The reducers object defines the actions that the slice owns: it generates the action creators and the action types. If a slice handled an action from another slice through the reducers object, it would generate a duplicate action creator with a different type string, and the dispatched action would not match. The extraReducers field handles actions from outside the slice without generating creators, so the action type and the action creator stay consistent with their origin .
The async-lifecycle problem.
A createAsyncThunk generates three action types: pending, fulfilled, and rejected. These actions are not defined in the slice’s reducers object; they are generated by the thunk. The slice handles them in extraReducers with addCase(fetchUser.pending, ...), addCase(fetchUser.fulfilled, ...), and addCase(fetchUser.rejected, ...). The builder infers the payload type from the thunk’s return type and the error type from the thunk’s reject value .
The cross-slice problem.
A resetAll action defined with createAction in a shared module is handled by multiple slices. Each slice adds a case in its extraReducers. The action is defined once, and the slices that care about it handle it without owning it. This is the pattern for logout, reset, and other actions that affect the entire application.
a. The builder callback form
The recommended syntax for extraReducers is the builder callback. The callback receives a builder object with addCase, addMatcher, and addDefaultCase methods.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
interface User {
id: number
name: string
}
interface UserState {
data: User | null
loading: boolean
error: string | null
}
export const fetchUser = createAsyncThunk<User, number>(
'user/fetch',
async (userId, { rejectWithValue }) => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) return rejectWithValue(`HTTP ${response.status}`)
return response.json()
}
)
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null } as UserState,
reducers: {
clearUser: (state) => {
state.data = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
// Immer draft: mutate directly
state.loading = true
state.error = null
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false
state.data = action.payload // Typed as User
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string // Typed from rejectWithValue
})
},
})
The addCase method accepts the action creator from the thunk and a reducer. The reducer’s action.payload is typed according to the thunk’s return type, and the rejected handler’s action.payload is typed according to the rejectWithValue argument . The builder chains the cases in a fluent interface, and each case handles one action type.
The addMatcher method accepts a type guard and a reducer. It handles multiple action types that match the guard, which is useful for handling every pending action from a group of thunks.
builder
.addMatcher(
(action) => action.type.endsWith('/pending'),
(state) => {
state.loading = true
}
)
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.loading = false
state.error = action.error.message ?? 'Unknown error'
}
)
The addMatcher cases are evaluated in the order they are added, after the addCase cases. If no case or matcher matches, the addDefaultCase reducer runs, if one is provided . The addMatcher form is a catch-all for action groups; it is less precise than addCase but more concise when many thunks share the same lifecycle handling.
b. The state-mutation rules inside extraReducers
The reducer inside addCase receives the Immer draft, just like a reducer in the reducers object. The same rules apply: mutate the draft and return nothing, or return a new value without mutating.
builder
.addCase(fetchUser.fulfilled, (state, action) => {
// Mutate the draft
state.data = action.payload
state.loading = false
})
.addCase(clearAll, (state) => {
// Return a new value instead of mutating
return { data: null, loading: false, error: null }
})
The fulfilled handler mutates the draft and returns nothing. The clearAll handler returns a new object without touching the draft. Mixing the two in the same reducer produces an Immer error, just as it does in the reducers object .
The state returned by the reducer must be a plain object. The draft is internal to Immer, and returning it directly would store a proxy in the state, which is not serializable and causes the development-time check to warn.
c. The object form and its limitations
Before the builder callback was introduced, extraReducers accepted an object with action types as keys and reducers as values. The object form is still supported, but it is not recommended for new code because it requires the action type strings to be written explicitly and does not provide the same TypeScript inference.
// Object form (legacy)
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false },
reducers: {},
extraReducers: {
[fetchUser.pending.type]: (state) => {
state.loading = true
},
[fetchUser.fulfilled.type]: (state, action) => {
state.loading = false
state.data = action.payload
},
},
})
The object form uses the .type property of the action creator as the key and the reducer as the value. The action argument is typed as AnyAction, so the payload type is not inferred. The builder callback form is preferred because it accepts the action creator directly and infers the payload type .
Complete Example Session
// ============================================
// PART 1: THE ASYNC THUNKS
// ============================================
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface Post { id: number; title: string; body: string }
export const fetchPosts = createAsyncThunk<Post[]>(
'posts/fetchAll',
async () => {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
)
export const createPost = createAsyncThunk<Post, { title: string; body: string }>(
'posts/create',
async (newPost, { rejectWithValue }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
if (!response.ok) return rejectWithValue(`HTTP ${response.status}`)
return response.json()
}
)
// ============================================
// PART 2: THE SHARED ACTION
// ============================================
import { createAction } from '@reduxjs/toolkit'
export const resetAll = createAction('app/resetAll')
// ============================================
// PART 3: THE SLICE WITH extraReducers
// ============================================
interface PostsState {
items: Post[]
loading: boolean
error: string | null
}
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], loading: false, error: null } as PostsState,
reducers: {
// ============================================
// PART 4: A LOCAL REDUCER
// ============================================
clearError: (state) => {
state.error = null
},
},
extraReducers: (builder) => {
builder
// ============================================
// PART 5: FETCH POSTS LIFECYCLE
// ============================================
.addCase(fetchPosts.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false
state.error = action.error.message ?? 'Failed to fetch'
})
// ============================================
// PART 6: CREATE POST LIFECYCLE
// ============================================
.addCase(createPost.fulfilled, (state, action) => {
state.items.push(action.payload)
})
.addCase(createPost.rejected, (state, action) => {
state.error = action.payload as string
})
// ============================================
// PART 7: SHARED RESET ACTION
// ============================================
.addCase(resetAll, (state) => {
state.items = []
state.loading = false
state.error = null
})
// ============================================
// PART 8: CATCH-ALL MATCHER
// ============================================
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state) => {
state.loading = false
}
)
},
})
// ============================================
// PART 9: THE ROOT REDUCER
// ============================================
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: {
posts: postsSlice.reducer,
},
})
// ============================================
// PART 10: DISPATCHING THE ACTIONS
// ============================================
import { useAppDispatch } from './hooks'
function PostControls() {
const dispatch = useAppDispatch()
return (
<div>
<button onClick={() => dispatch(fetchPosts())}>Fetch</button>
<button onClick={() => dispatch(createPost({ title: 'New', body: '...' }))}>Create</button>
<button onClick={() => dispatch(resetAll())}>Reset</button>
</div>
)
}
The ten parts demonstrate the complete extraReducers surface: the async thunks, the shared action, the local reducer, the three lifecycle handlers for fetchPosts, the two handlers for createPost, the shared reset action, the catch-all matcher, the root reducer, and the dispatch calls. The critical distinction is between Parts 4 and 5: the reducers object defines the local action, and the extraReducers builder handles the external actions.
Quick Reference
Builder Methods
| Method | Purpose | Argument |
|---|---|---|
addCase | Handle one action type | Action creator + reducer |
addMatcher | Handle multiple action types | Type guard + reducer |
addDefaultCase | Handle unmatched actions | Reducer |
Thunk Lifecycle Actions
| Action | When | Payload |
|---|---|---|
.pending | Request started | undefined |
.fulfilled | Request succeeded | Thunk’s return type |
.rejected | Request failed | rejectWithValue argument or Error |
Object vs Builder Form
| Form | Syntax | TypeScript Inference |
|---|---|---|
| Builder callback | (builder) => builder.addCase(...) | Full inference |
| Object form | { [action.type]: reducer } | Manual typing |
Best Practices
✅ Do This:
// Use the builder callback form
extraReducers: (builder) => { builder.addCase(...) } # ✅
// Use addCase for specific actions
.addCase(fetchUser.fulfilled, (state, action) => { ... }) # ✅
// Use addMatcher for action groups
.addMatcher((a) => a.type.endsWith('/pending'), (state) => { ... }) # ✅
// Mutate the draft inside the reducer
state.loading = true # ✅
// Use the thunk's action creators directly
addCase(fetchUser.pending, ...) # ✅
// Return a new value only when replacing the state
return initialState # ✅
❌ Don’t Do This:
// Define the thunk's actions in the reducers object
reducers: { [fetchUser.pending.type]: ... } // Wrong place # ❌
// Use the object form in new code
extraReducers: { [fetchUser.pending.type]: ... } # ❌
// Mutate and return in the same reducer
state.loading = false; return { ...state } # ❌
// Use addMatcher before addCase
// Matchers are evaluated after cases # ❌
// Forget the return type parameter on the thunk
createAsyncThunk('fetch', async () => { ... }) // payload is any # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Action not handled | Missing addCase | Add the case to extraReducers |
Payload type is any | Thunk’s return type not specified | createAsyncThunk<Data, Arg>(...) |
| Immer error | Mutated and returned | Choose one style |
| Matcher not matching | Wrong guard condition | Verify the action type pattern |
| Local reducer duplicated | Action defined in reducers and handled in extraReducers | Define it once |
Real-World Examples
1. Thunk pending
.addCase(fetchUser.pending, (state) => { state.loading = true })
2. Thunk fulfilled
.addCase(fetchUser.fulfilled, (state, action) => { state.data = action.payload; state.loading = false })
3. Thunk rejected
.addCase(fetchUser.rejected, (state, action) => { state.error = action.error.message })
4. Shared action
.addCase(resetAll, (state) => { state.items = [] })
5. Matcher for all pending
.addMatcher((a) => a.type.endsWith('/pending'), (state) => { state.loading = true })
6. Matcher for all rejected
.addMatcher((a) => a.type.endsWith('/rejected'), (state) => { state.loading = false })
7. Default case
.addDefaultCase((state) => state)
8. Local reducer
reducers: { clearError: (state) => { state.error = null } }
9. Return new state
.addCase(resetAll, () => initialState)
10. Multiple thunks
.addCase(fetchPosts.fulfilled, ...)
.addCase(createPost.fulfilled, ...)
Visual
extraReducers Handles External Actions
┌──────────────────────────────────────────────────────────────┐
│ reducers OWNS ACTIONS; extraReducers HANDLES OTHERS │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ reducers: { │ │
│ │ clearError: (state) => { ... } │ │
│ │ } │ │
│ │ → Generates action creator 'user/clearError' │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ extraReducers: (builder) => { │ │
│ │ builder │ │
│ │ .addCase(fetchUser.pending, ...) │ │
│ │ .addCase(fetchUser.fulfilled, ...) │ │
│ │ .addCase(fetchUser.rejected, ...) │ │
│ │ .addCase(resetAll, ...) │ │
│ │ } │ │
│ │ → Does not generate action creators │ │
│ │ → Handles actions defined elsewhere │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Thunk Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ createAsyncThunk DISPATCHES THREE ACTIONS │
│ │
│ dispatch(fetchUser(1)) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ pending │ │
│ │ → addCase(fetchUser.pending, ...) │ │
│ │ → state.loading = true │ │
│ └──────────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ fulfilled │ │ rejected │ │
│ │ → state.data = payload │ │ → state.error = message │ │
│ │ → state.loading = false │ │ → state.loading = false │ │
│ └──────────────────────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
addCase vs addMatcher
┌──────────────────────────────────────────────────────────────┐
│ addCase IS SPECIFIC; addMatcher IS A CATCH-ALL │
│ │
│ addCase: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ .addCase(fetchUser.fulfilled, (state, action) => { ... })│ │
│ │ → Handles exactly one action type │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ addMatcher: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ .addMatcher( │ │
│ │ (a) => a.type.endsWith('/pending'), │ │
│ │ (state) => { state.loading = true } │ │
│ │ ) │ │
│ │ → Handles every action that matches the guard │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Cases are evaluated before matchers. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Purpose | Handle actions defined outside the slice |
| Recommended form | Builder callback |
addCase | One action type |
addMatcher | Multiple action types via type guard |
addDefaultCase | Unmatched actions |
| State argument | Immer draft |
| Thunk actions | .pending, .fulfilled, .rejected |
| Shared actions | createAction defined in a module |
| Object form | Legacy; not recommended |
| Local reducers | Defined in reducers |
Key takeaways:
extraReducershandles actions the slice does not own. It does not generate action creators. It responds to actions fromcreateAsyncThunk,createAction, and other slices. Thereducersobject is for the slice’s own actions .- The builder callback form is the recommended syntax. It provides full TypeScript inference, handles the Immer draft correctly, and supports
addCase,addMatcher, andaddDefaultCase. The object form is legacy and lacks the inference . - The thunk lifecycle has three actions. The
pendingaction fires when the request starts, thefulfilledaction fires on success with the return value as the payload, and therejectedaction fires on failure with therejectWithValueargument as the payload . addMatcherhandles action groups. It accepts a type guard and a reducer. It is evaluated after theaddCasecases, so the specific cases take precedence. It is useful for handling everypendingorrejectedaction from a group of thunks .- The reducer inside
addCasereceives the Immer draft. It follows the same rules as a reducer in thereducersobject: mutate the draft and return nothing, or return a new value without mutating. Mixing the two produces an Immer error. - The shared action pattern uses
createActionandextraReducers. The action is defined once in a module, and each slice that cares about it adds a case in itsextraReducers. The action is not duplicated, and the type string stays consistent.
Remember: extraReducers is the bridge between a slice and the rest of the application. The reducers object defines what the slice owns; extraReducers handles what the slice reacts to. Use the builder callback, handle the thunk lifecycle with addCase, use addMatcher for groups, and define shared actions with createAction. The reducer receives the Immer draft, and the same mutation rules apply. The result is a slice that owns its actions and responds to the events that matter to it.
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!