| | |

Redux Toolkit 08 ⚛️ Action Reducers — Handling external actions with extraReducers

⏱️ Estimated read: 12 min | |

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

MethodPurposeArgument
addCaseHandle one action typeAction creator + reducer
addMatcherHandle multiple action typesType guard + reducer
addDefaultCaseHandle unmatched actionsReducer

Thunk Lifecycle Actions

ActionWhenPayload
.pendingRequest startedundefined
.fulfilledRequest succeededThunk’s return type
.rejectedRequest failedrejectWithValue argument or Error

Object vs Builder Form

FormSyntaxTypeScript 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

PitfallWhy It HappensFix
Action not handledMissing addCaseAdd the case to extraReducers
Payload type is anyThunk’s return type not specifiedcreateAsyncThunk<Data, Arg>(...)
Immer errorMutated and returnedChoose one style
Matcher not matchingWrong guard conditionVerify the action type pattern
Local reducer duplicatedAction defined in reducers and handled in extraReducersDefine 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

ItemValue
PurposeHandle actions defined outside the slice
Recommended formBuilder callback
addCaseOne action type
addMatcherMultiple action types via type guard
addDefaultCaseUnmatched actions
State argumentImmer draft
Thunk actions.pending, .fulfilled, .rejected
Shared actionscreateAction defined in a module
Object formLegacy; not recommended
Local reducersDefined in reducers

Key takeaways:

  • extraReducers handles actions the slice does not own. It does not generate action creators. It responds to actions from createAsyncThunk, createAction, and other slices. The reducers object 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, and addDefaultCase. The object form is legacy and lacks the inference .
  • The thunk lifecycle has three actions. The pending action fires when the request starts, the fulfilled action fires on success with the return value as the payload, and the rejected action fires on failure with the rejectWithValue argument as the payload .
  • addMatcher handles action groups. It accepts a type guard and a reducer. It is evaluated after the addCase cases, so the specific cases take precedence. It is useful for handling every pending or rejected action from a group of thunks .
  • The reducer inside addCase receives the Immer draft. It follows the same rules as a reducer in the reducers object: 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 createAction and extraReducers. The action is defined once in a module, and each slice that cares about it adds a case in its extraReducers. 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.


Was this helpful?


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!