Lesson 79·M9 State Management
Async Thunks & Selectors
OpenLearning Objectives
By the end of this lesson you should be able to:
- Explain
createAsyncThunk's pending/fulfilled/rejected lifecycle - Handle all three lifecycle actions in
extraReducers - Write a memoised selector with
createSelectorand know when it recomputes - Say why selectors are how the store reads, and how they beat reading state manually
- Model a fetch with loading/error/data state in one slice
One-line definition
createAsyncThunk turns an async function into three dispatches — pending, fulfilled,
rejected — handled in extraReducers, while createSelector memoises derived values so a
selector only recomputes when its inputs actually change.
Mental model
An async thunk is a restaurant order. You dispatch the order (pending) — the kitchen
starts cooking, the table shows "preparing". When the dish is ready it's brought out
(fulfilled) with the food; if the kitchen burns it, you get told (rejected) with the
reason. The table (the UI) never talks to the kitchen directly — it only reacts to those
three notifications.
A memoised selector is a marked cupboard. The first time you open it for a given configuration you sort everything inside; the next time, if nothing changed, you just grab the sorted result. Only when the contents change do you sort again.
Visual flow
component dispatches fetchUser(7) (createAsyncThunk)
│
▼
┌───────────────────────────────────────────────┐
│ STORE │
│ pending → { status: 'loading', error: null } ← dispatch #1 (sync)
│ │ await fetch(...) │
│ ├─ success → fulfilled { user, status: 'idle' } ← dispatch #2
│ └─ failure → rejected { error, status: 'idle' } ← dispatch #3
│ handled in extraReducers of the slice │
└───────────────────────────────────────────────┘
│
▼
component reads state.user.status with useSelector → renders loading / data / error
selectors (createSelector) read + memoise derived values, e.g. visible items.How it works
The async thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const fetchUser = createAsyncThunk(
'users/fetch', // action prefix — types become 'users/fetch/pending', etc.
async (id, { rejectWithValue }) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`); // → rejected
return await res.json(); // → fulfilled
}
);
The action types are derived automatically: users/fetch/pending, users/fetch/fulfilled,
users/fetch/rejected. The payload of fulfilled is whatever the async function returns; the
payload of rejected is what you threw or passed to rejectWithValue.
Handling the lifecycle — extraReducers
const userSlice = createSlice({
name: 'user',
initialState: { user: null, status: 'idle', error: null },
reducers: {
logout(state) { state.user = null; state.status = 'idle'; },
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'idle';
state.user = action.payload; // the resolved value
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'idle';
state.error = action.payload ?? 'failed to load';
});
},
});
Output:
No console output — but trace it:
fetchUser(7) dispatched → status 'loading'
→ resolve → action { type: 'users/fetch/fulfilled', payload: user } → user set
→ reject → action { type: 'users/fetch/rejected', payload: 'HTTP 500' } → error set
status is the single source of truth for the UI: loading / idle + user / idle + error.
Note
extraReducers is for actions defined outside the slice — like a thunk's three
lifecycle actions. It keeps the thunk's state transitions next to the slice they mutate,
without polluting the slice's own reducers block. The builder callback API is the
modern form; the older string-map form still appears in legacy code.
The memoised selector
import { createSelector } from '@reduxjs/toolkit';
const selectItems = (state) => state.cart.items;
const selectFilter = (state) => state.cart.filter;
export const selectVisibleItems = createSelector(
[selectItems, selectFilter], // input selectors
(items, filter) => // the "expensive" combine
items.filter((i) => i.name.includes(filter))
);
createSelector caches. It recomputes only when an input selector returns a new reference
— never on every store update. When the inputs are unchanged, it returns the previous result
by identity.
Real project usage
The status field is the standard way to model an async fetch:
function UserPage() {
const { user, status, error } = useSelector((state) => state.user);
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchUser(7)); }, [dispatch]);
if (status === 'loading') return <Spinner />;
if (status === 'idle' && error) return <Error message={error} />;
return user ? <Profile user={user} /> : <Empty />;
}
Three things to notice: the effect (Lesson 57) dispatches the thunk once, status drives
the UI branches, and the derived visibleItems selector is shared by components that would
otherwise each run the filter:
// two components, one memoised derivation
const items = useSelector(selectVisibleItems);
When one selector is composed from others — the "combine" stage recomputes only when the relevant input changed:
const selectVisibleTotal = createSelector(
[selectVisibleItems],
(visible) => visible.reduce((sum, i) => sum + i.price, 0)
);Interview explanation
"
createAsyncThunktakes an async function and generates three action types — pending, fulfilled and rejected — which I handle inextraReducers. The slice keeps astatusfield and the UI renders loading, data or error from it.""Selectors are pure functions from state to a value.
createSelectormemoises them: it only recomputes when an input selector returns a new reference, so derived values are stable across unrelated store updates. That's how the store reads efficiently."
Senior-level insights
- The three lifecycle actions are synchronous dispatches.
pendingfires before the await,fulfilled/rejectedafter. The reducer only ever sees plain, synchronous actions — which is why the whole reducer purity argument from Lesson 78 still holds. rejectWithValueis the canonical error path. It lets a rejected promise carry a meaningful payload (an API error object) intoaction.payload, instead of the raw error. Teams that skip it end up string-parsing errors in reducers.- The
statusfield is a state machine.idle → loading → idle(+data|+error)is a small, deliberate machine. Enumerate the states out loud in an interview and you sound like you've shipped this. createSelectorrecomputes on reference change, not content change. The filter string may be "same" semantically, but if a new string was created, it recomputes. Cache invalidation is by input identity — the exact Lesson 77/78 lesson, in selector form.- Memoised selectors exist for the same reason memo does (Lesson 67): keep derived values stable so subscribers don't re-render. The memo's dependency array and the selector's input list are the same idea.
- Compose selectors. Building
selectVisibleTotalfromselectVisibleItemskeeps each selector small and testable — pure functions of state (Lesson 14), testable without React.
Common mistakes
- Handling a thunk's actions in
reducersinstead ofextraReducers— the slice can't see the thunk's actions there. They belong inextraReducers. - Forgetting
pendingresets error — the UI keeps showing last time's error during a reload. Reseterrortonullin pending. - Loading state that never clears — if
fulfilled/rejecteddon't setstatusback toidle, the spinner spins forever. Every terminal action must set it. - Deriving in
useSelectorwithout memoisation — a new array per call re-renders the component on every store update (Lesson 78's selector-identity rule). createSelectorwith a spread return — returning a new object/array from the combine function is still a new reference each time the inputs change; that's correct, but it means downstream selectors recompute too. Keep combine functions pure and small.- Using
Date.now()or randomness inside a selector — a selector must be a pure function (Lesson 14), or memoisation gives stale/wrong results.
Best practices
✅ Model async as a status field: idle → loading → idle with error alongside
✅ Handle thunk lifecycle actions in extraReducers with the builder API
✅ Use rejectWithValue so rejected actions carry a useful payload
✅ Derive with createSelector when a useSelector computation is non-trivial
✅ Compose selectors from smaller selectors; keep each a pure function of state
✅ Reset error in pending; always return to idle in fulfilled and rejected
❌ Don't put side effects (fetch, logging, Date.now()) in reducers or selectors
❌ Don't create derived arrays inline in useSelector for expensive computations
❌ Don't read store state outside selectors — components should go through useSelector
Interview questions
Q1. What is createAsyncThunk?
A helper that wraps an async function and generates three action types —
pending,fulfilledandrejected. Dispatching the thunk fires pending synchronously, then fulfilled with the resolved value or rejected with the error. I handle all three inextraReducersand drive the UI from astatusfield.
Q2. What is extraReducers?
The place in a slice to handle actions defined elsewhere — specifically a thunk's three lifecycle actions.
reducersgenerates actions from the slice itself;extraReducerslets other actions update this slice's state. Thebuildercallback form (builder.addCase(...)) is the modern API.
Q3. Why do you need selectors, and what does createSelector add?
Selectors are pure functions from state to a value — the only sanctioned way to read the store.
createSelectormemoises them: it recomputes only when an input selector returns a new reference, and otherwise returns the cached result. That keeps derived values stable, so components don't re-render on every store update.
Q4. When does a memoised selector recompute?
When any of its input selectors returns a new reference — a new object or array, or a changed primitive. If the inputs are identical by reference, it returns the cached result without running the combine function. That's why the selector's performance depends on the inputs' stability.
Q5. How do you handle loading and error states?
A
statusfield on the slice —idle,loading, plus data or error when settled.pendingsetsstatus: 'loading'and clears the error;fulfilledstores the payload and goes back toidle;rejectedstores the error payload and goes back toidle. The component switches onstatusto render a spinner, the data, or an error.
Senior follow-up: How would you structure a slice for a fetch that has retry and caching?
The
statusmachine stays, but I'd think about server state properly: caching, retries and invalidation are TanStack Query's job (Lesson 81), not a hand-rolled thunk. Where I do need a thunk, I'd keep the slice thin — status plus data — and put retry/caching logic in the async function or a dedicated server-state layer, not in the reducer.
Follow-up questions
What's the difference between reducers and extraReducers?
reducersgenerates action creators from the slice itself.extraReducershandles actions created elsewhere — a thunk's lifecycle actions, or another slice's actions — without generating new creators. Same reducer shape, different origin of the action.
Can two thunks update the same slice?
Yes —
extraReducerscanaddCasefor any thunk. That's how a shared UI slice reacts to several fetches. Just keep each case small and the status transitions explicit.
How do you cancel or avoid stale results?
Use an
AbortController(from Lesson 27's error handling) or ignore a latefulfilled/rejected— e.g. a generation counter, or TanStack Query's cancellation (Lesson 81). The three-action lifecycle is a contract, but nothing stops a stale response arriving after a newer one; guard against it.
What's the difference between a selector and useMemo?
A selector is a pure function of state, reusable and testable outside React.
useMemocaches a computation inside one component instance. A memoised selector gives the same caching benefit but shared across components — the React-free, store-aware version of the same idea (Lesson 61).
Comparison table
Plain useEffect fetch | Thunk (L79) | TanStack Query (L81) | |
|---|---|---|---|
| Where state lives | Local useState | Redux slice (status, data, error) | Its own cache |
| Loading/error handling | Hand-rolled per component | Three lifecycle actions | Built-in |
| Retries | Manual | Manual | Built-in |
| Caching / invalidation | None | None | Built-in |
| Best for | One-off local fetch | Global state driven by server | Heavy server state |
| Plain selector | createSelector | |
|---|---|---|
| Recomputation | Every call | Only when inputs change reference |
| Memoised | ❌ | ✅ |
| Pure function | ✅ | ✅ |
| Best for | Cheap reads | Derived / expensive reads |
Code example
The full lifecycle, simulated in plain JS — no Redux needed:
function createAsyncThunk(prefix, asyncFn) {
return (payload) => {
const dispatch = (type, extra = {}) =>
events.push({ type: `${prefix}/${type}`, ...extra });
dispatch('pending');
return asyncFn(payload).then(
(result) => { dispatch('fulfilled', { payload: result }); return result; },
(err) => { dispatch('rejected', { payload: err.message }); throw err; }
);
};
}
const events = [];
const fetchUser = createAsyncThunk('users/fetch', async (id) => {
if (id < 0) throw new Error('not found'); // rejection path
return { id, name: 'Ali' }; // resolution path
});
await fetchUser(7).catch(() => {});
await fetchUser(-1).catch(() => {});
console.log(events);
Output:
[
{ type: 'users/fetch/pending' },
{ type: 'users/fetch/fulfilled', payload: { id: 7, name: 'Ali' } },
{ type: 'users/fetch/pending' },
{ type: 'users/fetch/rejected', payload: 'not found' }
]
And the memoisation, in plain JS:
function createSelector(inputs, compute) {
let lastInputs = null;
let lastResult = null;
return (state) => {
const current = inputs.map((f) => f(state));
if (lastInputs !== null && current.every((v, i) => v === lastInputs[i])) {
return lastResult; // cache hit — no recompute
}
lastInputs = current;
lastResult = compute(...current);
return lastResult;
};
}
let calls = 0; // counts combine invocations
const selectItems = (s) => s.items;
const selectVisible = createSelector(
[selectItems],
(items) => {
calls += 1; // runs only when the input reference changes
return items.filter((i) => i.visible);
},
);
const state1 = { items: [{ id: 1, visible: true }, { id: 2, visible: false }] };
selectVisible(state1);
selectVisible(state1); // same state reference — cached
console.log('calls with same state:', calls); // 1
const state2 = { items: [...state1.items] }; // new array reference
selectVisible(state2);
console.log('calls with new reference:', calls); // 2
Output:
calls with same state: 1
calls with new reference: 2
The second read of the same state object hits the cache. The moment the items array gets a
new reference, the combine runs again — exactly how createSelector behaves in RTK.
Performance notes
- A thunk's lifecycle is cheap. Three dispatches, each a synchronous reducer call. The
cost is in re-renders: every slice update notifies every subscriber, so the selector
stability from
createSelectoris what actually keeps the tree quiet. createSelectoris a cache, not a guarantee. It caches one result per input combination. If your state legitimately produces many distinct inputs, you get cache misses — which is correct behaviour, not a leak.- When it matters: a large store with many subscribers and derived lists — the filter case above. Without memoisation, every store update re-filters and re-renders.
- When it doesn't: a handful of subscribers reading primitives.
createSelectoradds a layer; don't add it to a selector that returnsstate.cart.totaldirectly. - The real cost is subscription, not computation. Memoised selectors reduce re-renders,
which is the same lever Lesson 67's
memopulls — fewer renders, not faster math.
Debugging scenarios
Scenario 1 — "Spinner never stops"
fulfilled or rejected doesn't set status back to idle. Check extraReducers has all
three cases, and that status is set on both terminal actions.
Scenario 2 — "Component re-renders on every store update"
The selector returns a new array/object each call (an inline filter or reduce). Memoise with
createSelector or select a primitive.
Scenario 3 — "Error shows during reload"
pending doesn't reset error to null, so the old error renders while loading. Clear
error in the pending case.
Scenario 4 — "Stale data wins"
A slow request resolves after a newer one. Guard with an AbortController (Lesson 27) or a
request-generation counter, or move to a server-state layer (Lesson 81).
Scenario 5 — "Memoised selector returns stale value"
One of the input selectors returns a value that looks the same but is a new reference — or an input selector reads a field that changed elsewhere. The cache keys on input identity; make the input selectors read exactly what the combine uses.
Quick revision notes
createAsyncThunk('users/fetch', fn)→pending/fulfilled/rejectedaction types- Handle them in
extraReducerswith thebuilderAPI status:idle → loading → idle(+userorerror) — a small state machinerejectWithValuecarries a useful payload into the rejected action- Selectors are pure functions of state;
useSelectoris how components read createSelectorrecomputes only when an input returns a new reference- Memoised selectors reduce re-renders — the Lesson 67
memoidea, store-side - Reducers and selectors stay pure (Lesson 14) — side effects belong in the thunk
- Server-state concerns (caching, retries) belong in TanStack Query (Lesson 81)
Cheat sheet
// thunk
export const fetchUser = createAsyncThunk(
'users/fetch',
async (id, { rejectWithValue }) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`);
return await res.json();
}
);
// slice — lifecycle in extraReducers
const userSlice = createSlice({
name: 'user',
initialState: { user: null, status: 'idle', error: null },
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (s) => { s.status = 'loading'; s.error = null; })
.addCase(fetchUser.fulfilled, (s, a) => { s.status = 'idle'; s.user = a.payload; })
.addCase(fetchUser.rejected, (s, a) => { s.status = 'idle'; s.error = a.payload; });
},
});
// memoised selector
const selectVisible = createSelector(
[(s) => s.cart.items, (s) => s.cart.filter],
(items, filter) => items.filter((i) => i.name.includes(filter))
);
// in a component
const status = useSelector((s) => s.user.status);
dispatch(fetchUser(7));Key takeaways
Recap
createAsyncThunkturns one async function into pending/fulfilled/rejected dispatchesextraReducersis where those lifecycle actions update the slice- A
statusfield (idle → loading → idle) is the canonical way to model async UI rejectWithValuekeeps rejected actions meaningful- Selectors are pure functions from state to a value (Lesson 14, applied)
createSelectormemoises: recompute only when an input selector changes reference- Memoised selectors prevent re-renders — the same lever as
memo(Lesson 67) - Server state at scale is TanStack Query's job (Lesson 81); thunks cover the rest
- Foundational: Lessons 81 and 82 build directly on this
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/7Name the three action types createAsyncThunk generates, and when each dispatches.
Why do lifecycle actions go in extraReducers and not reducers?
What happens to the UI if you forget to set status back to idle on rejection?
When does a createSelector selector recompute — and when does it hit the cache?
Why does an inline derived array in useSelector re-render on every store update?
How does rejectWithValue improve error handling over throwing?
Where would you put caching and retries instead of hand-rolling them in a thunk?
What's Next
Lesson 80 — Zustand. The modern lightweight alternative — a store with no boilerplate, no provider tree, and selector-like subscriptions. Expect the comparison question: "When Zustand, when Redux, when context?"