🍰 createSlice and createAsyncThunk
The last lesson introduced the toolkit; this one gets your hands dirty with its two workhorses. createSlice is where your feature's state and synchronous logic live. createAsyncThunk is how that feature talks to the network. Master the way they fit together — a slice's extraReducers catching a thunk's lifecycle — and you can model almost any feature in modern Redux.
Week 6 · Thursday: Redux Toolkit · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Write slice reducers in every form: simple,
prepare-based, and reset reducers - Use the
builderinextraReducerswithaddCase,addMatcher, andaddDefaultCase - Trace a
createAsyncThunkthrough its pending → fulfilled → rejected lifecycle - Use the
thunkAPIobject:getState,dispatch,rejectWithValue, andsignal - Combine a slice and a thunk into one complete, working feature
- Read a thunk's result in a component with
.unwrap()
Estimated Time: 70 minutes
Practice: Build a posts feature — a slice plus a fetch thunk wired through extraReducers.
In This Lesson
Anatomy of a Slice
createSlice accepts four things and returns two. In goes a name, an initialState, a reducers object, and an optional extraReducers callback. Out come the generated actions and a single reducer.
🍰 The Layer Cake Analogy
A slice is a layer cake for one feature. The name is the label on the box. The initialState is the base layer. The reducers are the layers you own and stack yourself. extraReducers are decorations borrowed from other cakes — actions baked elsewhere that your slice reacts to. And the actions are the frosting that ties it all together so components can order a slice.
import { createSlice } from '@reduxjs/toolkit';
const featureSlice = createSlice({
name: 'feature', // seeds action types: 'feature/actionName'
initialState: { /* ... */ },
reducers: { // actions this slice OWNS (creators generated)
actionName: (state, action) => {
// "mutate" the Immer draft directly
}
},
extraReducers: (builder) => { // REACT to actions defined elsewhere
builder.addCase(someExternalAction, (state, action) => { /* ... */ });
}
});
export const { actionName } = featureSlice.actions; // action creators
export default featureSlice.reducer; // the reducer
Reducer Patterns
Inside reducers, each key can take one of three shapes. Knowing all three lets you keep components dumb and logic centralized.
1. Simple reducer
setFilter: (state, action) => {
state.filter = action.payload; // whatever you dispatch is action.payload
}
2. Reducer with a prepare callback
When the stored payload needs generated fields (id, timestamps) or validation, split the logic: prepare builds the action, reducer applies it.
import { nanoid } from '@reduxjs/toolkit';
addTodo: {
reducer: (state, action) => {
state.items.push(action.payload);
},
prepare: (text) => ({
payload: {
id: nanoid(), // RTK ships its own id generator
text,
completed: false,
createdAt: new Date().toISOString()
}
})
}
// Component only supplies the text:
dispatch(addTodo('Write the report'));
💡 prepare for validation too
updateTodo: {
reducer: (state, action) => {
const { id, updates } = action.payload;
const todo = state.items.find(t => t.id === id);
if (todo) Object.assign(todo, updates);
},
prepare: (id, updates) => {
if (!id) throw new Error('id is required');
return { payload: { id, updates } };
}
}
3. Reset reducer (return a new value)
To wipe state back to a known shape, return a fresh object instead of mutating. Remember the Immer rule: return-only, don't also touch the draft.
resetTodos: () => ({ items: [], filter: 'all', loading: false })
extraReducers & the Builder
extraReducers receives a builder object with a small, chainable API. This is the modern, TypeScript-friendly way to respond to actions your slice doesn't own — most importantly, async thunks.
| Builder method | Matches | Typical use |
|---|---|---|
addCase(action, fn) | One exact action type | A thunk's .pending / .fulfilled / .rejected |
addMatcher(predicate, fn) | Any action passing a test | Every action ending in /rejected |
addDefaultCase(fn) | Anything not matched above | A fallback (rarely needed) |
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null },
reducers: {
postAdded: (state, action) => { state.items.push(action.payload); }
},
extraReducers: (builder) => {
builder
// exact matches, in order
.addCase(fetchPosts.pending, (state) => { state.status = 'loading'; })
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
// catch-all for ANY rejected thunk across the app
.addMatcher(
(action) => action.type.endsWith('/rejected'),
(state, action) => { state.error = action.error?.message ?? 'Unknown error'; }
);
}
});
⚠️ Order matters, and always call addCase first
The builder applies addCase handlers, then addMatcher handlers (in the order added), then addDefaultCase. All matching handlers run, so a broad matcher won't replace a specific case — it runs after it. The old object-notation form of extraReducers is deprecated; always use the builder callback.
The Async Thunk Lifecycle
createAsyncThunk takes a type prefix and an async "payload creator." Dispatching the thunk immediately fires a pending action; when your async function resolves it fires fulfilled (with the return value as payload); if it throws it fires rejected.
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserById = createAsyncThunk(
'users/fetchById', // → 'users/fetchById/pending' | '/fulfilled' | '/rejected'
async (userId) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Request failed');
return res.json(); // this becomes action.payload on fulfilled
}
);
// The thunk object carries the three generated action creators:
fetchUserById.pending.type; // 'users/fetchById/pending'
fetchUserById.fulfilled.type; // 'users/fetchById/fulfilled'
fetchUserById.rejected.type; // 'users/fetchById/rejected'
In a component
const dispatch = useDispatch();
const { entities, loading, error } = useSelector(state => state.users);
useEffect(() => { dispatch(fetchUserById(userId)); }, [dispatch, userId]);
if (loading) return <Spinner />;
if (error) return <p>Error: {error}</p>;
Working with thunkAPI
The payload creator's second argument is thunkAPI — a toolbox for talking to the rest of the store from inside async logic. The four you'll use constantly:
getState()— read current state (e.g. grab an auth token)dispatch()— fire other actions or thunksrejectWithValue(value)— reject with a custom payload instead of a thrown Errorsignal— anAbortControllersignal for cancellation
Custom error payloads with rejectWithValue
A thrown Error only exposes its message on action.error. When your API returns a structured error (validation fields, a code), use rejectWithValue so the whole object lands on action.payload.
export const loginUser = createAsyncThunk(
'auth/login',
async (credentials, { getState, dispatch, rejectWithValue }) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!res.ok) {
const problem = await res.json();
return rejectWithValue(problem); // → action.payload in the reducer
}
const data = await res.json();
if (getState().settings.rememberMe) {
localStorage.setItem('token', data.token);
}
dispatch(fetchUserProfile()); // chain another thunk
return data;
}
);
// In the slice:
.addCase(loginUser.rejected, (state, action) => {
// action.payload holds the server's structured error (from rejectWithValue)
// action.error.message holds a thrown error's message (the fallback)
state.error = action.payload?.message ?? action.error.message;
})
Skipping unnecessary work with condition
A third argument to createAsyncThunk lets you bail out before the request even starts — great for avoiding duplicate fetches.
export const fetchNotifications = createAsyncThunk(
'notifications/fetch',
async () => (await fetch('/api/notifications')).json(),
{
condition: (_arg, { getState }) => {
const { status, lastFetched } = getState().notifications;
if (status === 'loading') return false; // already in flight
if (lastFetched && Date.now() - lastFetched < 60_000) return false; // still fresh
return true;
}
}
);
Cancellation with signal
export const searchProducts = createAsyncThunk(
'products/search',
async (query, { signal }) => {
const res = await fetch(`/api/products?q=${query}`, { signal });
return res.json();
}
);
// Dispatching returns a promise with an .abort() method:
const promise = dispatch(searchProducts('laptop'));
promise.abort(); // cancels the in-flight request → rejected with 'Aborted'
A Complete Feature
Here is a realistic posts feature that ties everything together: async thunks with error handling, sync reducers, extraReducers wiring, and co-located selectors.
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit';
import { client } from '../../api/client';
// --- Thunks ---
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const res = await client.get('/api/posts');
return res.data;
});
export const addNewPost = createAsyncThunk(
'posts/addNewPost',
async (initialPost, { getState, rejectWithValue }) => {
try {
const { auth } = getState();
const post = { ...initialPost, author: auth.user.id, date: new Date().toISOString() };
const res = await client.post('/api/posts', post);
return res.data;
} catch (err) {
return rejectWithValue(err.response.data);
}
}
);
// --- Slice ---
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null },
reducers: {
reactionAdded: (state, action) => {
const { postId, reaction } = action.payload;
const post = state.items.find(p => p.id === postId);
if (post) post.reactions[reaction]++;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => { state.status = 'loading'; })
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(addNewPost.fulfilled, (state, action) => {
state.items.push(action.payload);
});
}
});
export const { reactionAdded } = postsSlice.actions;
export default postsSlice.reducer;
// --- Selectors (co-located) ---
export const selectAllPosts = (state) => state.posts.items;
export const selectPostById = (state, id) => state.posts.items.find(p => p.id === id);
// Memoized derived selector
export const selectPostsByUser = createSelector(
[selectAllPosts, (_state, userId) => userId],
(posts, userId) => posts.filter(p => p.author === userId)
);
💡 Why createSelector?
selectPostsByUser creates a new filtered array each call. Wrapping it in createSelector memoizes the result — it only recomputes when posts or userId actually change, preventing needless component re-renders.
Reading Results with unwrap
Dispatching a thunk returns a special promise that always resolves — even on failure — so a bare await won't throw. Call .unwrap() to get a normal promise that resolves with the payload or rejects on failure, letting you use plain try/catch in the component.
const AddPostForm = () => {
const dispatch = useDispatch();
const [status, setStatus] = useState('idle');
const onSave = async (fields) => {
try {
setStatus('pending');
// .unwrap() throws if the thunk rejected → jumps to catch
const savedPost = await dispatch(addNewPost(fields)).unwrap();
console.log('Saved post id:', savedPost.id);
} catch (err) {
console.error('Failed to save the post:', err);
} finally {
setStatus('idle');
}
};
// ...
};
✅ The rule of thumb
Handle loading/error state declaratively via the slice (from selectors). Use .unwrap() only when the component needs to react to this specific dispatch's outcome — like resetting a form or navigating on success.
Practice & Quiz
🏋️ Exercise 1: Wire a fetch thunk into a slice
Goal: Given a fetchTodos thunk, complete a todos slice that tracks status and stores the result.
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('/api/todos');
return res.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
// TODO: handle pending, fulfilled, rejected
}
});
💡 Hint
Use builder.addCase(fetchTodos.pending, ...) etc. On fulfilled, set state.items = action.payload. On rejected, read action.error.message.
✅ Solution
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; state.error = null; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
}
🏋️ Exercise 2: Return a structured error
Goal: Rewrite this thunk so a 400 response surfaces the server's message on action.payload instead of a generic thrown error.
export const saveTodo = createAsyncThunk('todos/save', async (todo) => {
const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) });
return res.json(); // no error handling yet
});
✅ Solution
export const saveTodo = createAsyncThunk(
'todos/save',
async (todo, { rejectWithValue }) => {
const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify(todo) });
const data = await res.json();
if (!res.ok) return rejectWithValue(data); // data.message lands on action.payload
return data;
}
);
🎯 Quick Quiz
Question 1: A createAsyncThunk's async function returns a value. Which action carries it, and where?
Question 2: You need a structured error object in the reducer, not just a message. What do you use?
Question 3: Why call .unwrap() on a dispatched thunk in a component?
Best Practices & Pitfalls
✅ Do
- Handle all three thunk states —
pending,fulfilled,rejected— every time - Use the
buildercallback form ofextraReducers(the object form is deprecated) - Use
prepareto keep id/timestamp generation out of components - Use
rejectWithValuefor structured API errors; read them fromaction.payload - Co-locate selectors with the slice and memoize derived ones with
createSelector
❌ Don't
- Make API calls inside a reducer — reducers must stay pure; that's the thunk's job
- Both mutate the draft and
returnit in one reducer (Immer throws) await dispatch(thunk())expecting it to throw — it won't; use.unwrap()- Reach into raw state shape from components — go through selectors
⚠️ A common race-condition guard
If a screen can trigger the same fetch twice, track the request id so a stale response can't clobber a newer one:
.addCase(fetchPosts.pending, (state, action) => {
if (state.status === 'idle') {
state.status = 'loading';
state.currentRequestId = action.meta.requestId;
}
})
.addCase(fetchPosts.fulfilled, (state, action) => {
if (state.currentRequestId === action.meta.requestId) {
state.status = 'succeeded';
state.items = action.payload;
state.currentRequestId = undefined;
}
})
Summary
🎉 Key Takeaways
- createSlice takes name + initialState + reducers (+ extraReducers) and returns actions + a reducer
- Reducers come in three shapes: simple, prepare-based, and reset (return-only)
- extraReducers uses a
builder:addCasefor exact types,addMatcherfor predicates - createAsyncThunk generates
pending/fulfilled/rejectedfrom one async function - thunkAPI gives you
getState,dispatch,rejectWithValue, andsignal - Use
.unwrap()when a component must react to a specific dispatch's result
📚 Additional Resources
- createSlice — API reference
- createAsyncThunk — API reference
- Redux Toolkit — Usage Guide
- Redux Essentials — Async Logic and Data Fetching
🚀 What's Next?
Writing thunks for every fetch, plus tracking loading/error state by hand, still adds up. The next lesson introduces RTK Query — a data-fetching layer built on these same primitives that generates the thunks, the cache, and the React hooks for you.
🎉 The workhorses, mastered!
You can now model any feature: state, sync logic, and async data flow, all in one tidy slice.