createAsyncThunk

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.

One thunk, three generated actions, one slice reacting to themJavaScript
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);
Output
products/fetch/pending
products/fetch/fulfilled
products/fetch/pending
products/fetch/rejected
failed | [ { id: 1, name: 'Mug' } ] | no such page

The 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.