Every hand-written data-loading thunk dispatches the same three actions: started, succeeded, failed. createAsyncThunk takes a type prefix and an async payload creator and generates all three — prefix/pending, prefix/fulfilled and prefix/rejected — exposed as thunk.pending and friends.
const fetchProducts = createAsyncThunk('products/fetch',
async (page, { rejectWithValue }) => {
if (page > 1) return rejectWithValue('no such page'); // an expected failure
await new Promise(r => setTimeout(r, 50)); // stands in for fetch()
return [{ id: 1, name: 'Mug' }]; // becomes action.payload
},
{ condition: (page, { getState }) => getState().products.status !== 'loading' });
const products = createSlice({
name: 'products', initialState: { status: 'idle', items: [], error: null }, reducers: {},
extraReducers: builder => builder
.addCase(fetchProducts.pending, s => { s.status = 'loading'; })
.addCase(fetchProducts.fulfilled, (s, a) => { s.status = 'ready'; s.items = a.payload; })
.addCase(fetchProducts.rejected, (s, a) => { s.status = 'failed'; s.error = a.payload; }),
});
const log = () => next => action => { console.log(action.type); return next(action); };
const store = configureStore({ reducer: { products: products.reducer },
middleware: g => g().concat(log) });
for (const page of [0, 2]) await store.dispatch(fetchProducts(page));
const { status, items, error } = store.getState().products;
console.log(status, '|', items, '|', error);products/fetch/pending
products/fetch/fulfilled
products/fetch/pending
products/fetch/rejected
failed | [ { id: 1, name: 'Mug' } ] | no such pageThe second argument, the thunkAPI object, carries most of the value. rejectWithValue(x) turns a handled failure — a 404, a validation error — into rejected with action.payload set to x, where a thrown error gives you only a serialized message under action.error. Its signal is an AbortSignal tied to the promise's .abort(), so passing it to fetch makes cancellation work end to end. The condition option dedupes: return false and nothing is dispatched, not even pending.
store.dispatch(thunk()) returns a promise that always fulfills, with the fulfilled or the rejected action, so await in a component will not throw. Chain .unwrap() when you want the error: it resolves with the payload or throws, which fits a try/catch.