L6: Extra Reducers
Handle async thunk states with extraReducers
Now let's handle the async thunk's three states: pending, fulfilled, and rejected!
Reducers vs Extra Reducers
Redux Toolkit slices have two ways to handle actions:
reducers - Actions generated BY this slice:
const listingsSlice = createSlice({
name: 'listings',
initialState,
reducers: {
toggleFavorite: (state, action) => {
// Handles: listings/toggleFavorite
},
clearFilters: (state) => {
// Handles: listings/clearFilters
}
}
});- Actions are auto-generated
- Action creators exported:
toggleFavorite(),clearFilters() - Used for synchronous state updates
extraReducers - Actions generated OUTSIDE this slice:
const listingsSlice = createSlice({
name: 'listings',
initialState,
reducers: { /* ... */ },
extraReducers: (builder) => {
builder
.addCase(fetchListings.pending, (state) => {
// Handles: listings/fetchListings/pending
})
.addCase(fetchListings.fulfilled, (state, action) => {
// Handles: listings/fetchListings/fulfilled
})
.addCase(fetchListings.rejected, (state, action) => {
// Handles: listings/fetchListings/rejected
});
}
});- Handles external actions (async thunks, other slices)
- Actions NOT auto-generated (they come from thunks)
- Used for asynchronous operations
| Feature | reducers | extraReducers |
|---|---|---|
| Purpose | Sync actions from this slice | Actions from outside (thunks, other slices) |
| Actions | Auto-generated | External (must reference) |
| Export | Action creators exported | No exports (handles existing actions) |
| Use case | Toggle favorite, clear form | API calls, complex async logic |
| Syntax | Object notation | Builder callback |
Rule of thumb:
- Simple state changes →
reducers - Async operations →
extraReducers
What We're Building
We'll add extraReducers to handle all three fetch states:
- Pending - Set loading state
- Fulfilled - Store fetched listings
- Rejected - Store error message
Step 1: Add extraReducers
Open src/state/slices/listingsSlice.js and add extraReducers after the reducers:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';
const initialState = {
items: [],
favorites: [],
status: 'idle',
error: null,
};
export const fetchListings = createAsyncThunk(
'listings/fetchListings',
async (filters = {}) => {
const response = await api.get('/listings', {
params: filters,
});
return response.data;
}
);
const listingsSlice = createSlice({
name: 'listings',
initialState,
reducers: {
toggleFavorite: (state, action) => {
const id = action.payload;
if (state.favorites.includes(id)) {
state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
} else {
state.favorites.push(id);
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchListings.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchListings.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchListings.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;What's happening here?
Builder Pattern
extraReducers: (builder) => {
builder
.addCase(/* ... */)
.addCase(/* ... */)
.addCase(/* ... */);
}The builder object provides methods to handle different action types:
addCase()- Handle specific action typeaddMatcher()- Handle multiple actions matching a patternaddDefaultCase()- Handle all other actions
We use method chaining to handle multiple cases cleanly.
Pending Case
.addCase(fetchListings.pending, (state) => {
state.status = 'loading';
state.error = null;
})When: Dispatched when fetchListings() starts
What it does:
- Sets
statusto'loading' - Clears previous errors
Use in UI:
const status = useSelector((state) => state.listings.status);
if (status === 'loading') return <Spinner />;Fulfilled Case
.addCase(fetchListings.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})When: Dispatched when API call succeeds
What it does:
- Sets
statusto'succeeded' - Stores fetched data in
state.items
action.payload contains the return value from the thunk:
// In thunk
return response.data; // ← This
// In reducer
state.items = action.payload; // ← Becomes thisRejected Case
.addCase(fetchListings.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})When: Dispatched when API call fails
What it does:
- Sets
statusto'failed' - Stores error message
Use in UI:
const error = useSelector((state) => state.listings.error);
if (error) return <ErrorMessage message={error} />;Complete State Flow
Let's trace the complete state changes when fetching listings:
Initial State
{
items: [],
favorites: [],
status: 'idle',
error: null
}After Dispatch
dispatch(fetchListings());Pending action fires:
{
items: [],
favorites: [],
status: 'loading', // ← Changed
error: null
}After Success
Fulfilled action fires:
{
items: [/* API data */], // ← Populated
favorites: [],
status: 'succeeded', // ← Changed
error: null
}After Error (Alternative)
If the API call failed, rejected action fires:
{
items: [],
favorites: [],
status: 'failed', // ← Changed
error: 'Network request failed' // ← Set
}Using Status in Components
Now components can check the status:
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchListings } from '@/state/slices/listingsSlice';
function HomePage() {
const dispatch = useDispatch();
const { items, status, error } = useSelector((state) => state.listings);
useEffect(() => {
if (status === 'idle') {
dispatch(fetchListings());
}
}, [status, dispatch]);
if (status === 'loading') {
return <div>Loading...</div>;
}
if (status === 'failed') {
return <div>Error: {error}</div>;
}
return (
<div>
{items.map(listing => (
<div key={listing.id}>{listing.title}</div>
))}
</div>
);
}Much cleaner than local state! All loading/error logic is in Redux.
extraReducers Syntax Options
There are two ways to write extraReducers:
extraReducers: (builder) => {
builder
.addCase(fetchListings.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchListings.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchListings.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
}Pros:
- ✅ Better TypeScript support
- ✅ Catches typos and errors
- ✅ Recommended by Redux Toolkit
- ✅ Chainable syntax
extraReducers: {
[fetchListings.pending]: (state) => {
state.status = 'loading';
},
[fetchListings.fulfilled]: (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
},
[fetchListings.rejected]: (state, action) => {
state.status = 'failed';
state.error = action.error.message;
}
}Cons:
- ❌ Deprecated in newer versions
- ❌ Worse TypeScript support
- ❌ Harder to catch errors
Use the builder callback pattern!
Advanced Patterns
Pattern 1: Handle Multiple Thunks
extraReducers: (builder) => {
builder
// fetchListings thunk
.addCase(fetchListings.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchListings.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
// fetchListingDetails thunk
.addCase(fetchListingDetails.pending, (state) => {
state.detailsStatus = 'loading';
})
.addCase(fetchListingDetails.fulfilled, (state, action) => {
state.detailsStatus = 'succeeded';
state.currentListing = action.payload;
});
}Pattern 2: Use Matcher
Handle all pending actions at once:
extraReducers: (builder) => {
builder
.addMatcher(
(action) => action.type.endsWith('/pending'),
(state) => {
state.status = 'loading';
}
)
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state) => {
state.status = 'succeeded';
}
)
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state.status = 'failed';
state.error = action.error.message;
}
);
}Use when: You have many thunks with similar patterns.
Pattern 3: Default Case
extraReducers: (builder) => {
builder
.addCase(fetchListings.fulfilled, (state, action) => {
state.items = action.payload;
})
.addDefaultCase((state, action) => {
// Handle any other action
console.log('Unhandled action:', action.type);
});
}Testing in Redux DevTools
Let's verify everything works:
Open Redux DevTools
- Open your app in browser
- Open DevTools (F12)
- Click Redux tab
Dispatch Manually
In the Dispatch section, manually dispatch:
{
"type": "listings/fetchListings/pending"
}Check state - you should see:
{
"listings": {
"status": "loading",
"error": null
}
}Dispatch Fulfilled
Now dispatch:
{
"type": "listings/fetchListings/fulfilled",
"payload": [
{ "id": 1, "title": "Test Listing" }
]
}Check state:
{
"listings": {
"items": [
{ "id": 1, "title": "Test Listing" }
],
"status": "succeeded"
}
}Perfect! 🎉
Complete Code
Here's the complete slice with extraReducers:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import api from '@/api';
const initialState = {
items: [],
favorites: [],
status: 'idle',
error: null,
};
export const fetchListings = createAsyncThunk(
'listings/fetchListings',
async (filters = {}) => {
const response = await api.get('/listings', {
params: filters,
});
return response.data;
}
);
const listingsSlice = createSlice({
name: 'listings',
initialState,
reducers: {
toggleFavorite: (state, action) => {
const id = action.payload;
if (state.favorites.includes(id)) {
state.favorites = state.favorites.filter(favoriteId => favoriteId !== id);
} else {
state.favorites.push(id);
}
},
},
extraReducers: (builder) => {
builder
.addCase(fetchListings.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchListings.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchListings.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { toggleFavorite } = listingsSlice.actions;
export default listingsSlice.reducer;What's Next?
Excellent! The slice now handles async fetch states. In the next lesson, we'll:
- Refactor HomePage - Use Redux instead of local state
- Dispatch fetchListings - Load data on mount
- Remove duplicate code - Eliminate local fetch logic
✅ Lesson Complete! Your slice now handles all three async states: pending, fulfilled, and rejected!
Key Takeaways
- ✅ Use
reducersfor sync actions generated by the slice - ✅ Use
extraReducersfor external actions (thunks, other slices) - ✅ Builder callback pattern is recommended over map object
- ✅ Handle three states: pending, fulfilled, rejected
- ✅
action.payloadcontains the thunk's return value - ✅
action.errorcontains error information - ✅ Status field tracks: idle → loading → succeeded/failed