Redux & Redux Toolkit — App-Wide State ManagementEntry 9 of 11

Async Logic with createAsyncThunk

createAsyncThunk wraps an async function and dispatches pending, fulfilled, and rejected actions automatically.

Notes

  • Reducers must stay pure and synchronous. HTTP calls go in thunks (React) or effects (Angular).
  • The slice handles the three lifecycle actions in extraReducers, which responds to actions defined outside the slice.
  • Throwing inside the thunk triggers rejected; the error message is in action.error.message.
  • Dispatch the thunk like any action: dispatch(fetchCart()).

What / Why / When

What

Async side effects tied to the store.

Why

Keeps loading/error/data handling in one place instead of in every component.

When

Loading or saving shared data from an API.

Code compare

React
// store/cart-slice.tsimport { createAsyncThunk, createSlice } from "@reduxjs/toolkit";​type CartItem = { id: string; title: string; quantity: number; price: number };​type CartState = {  items: CartItem[];  status: "idle" | "loading" | "failed";  error: string | null;};​const initialState: CartState = { items: [], status: "idle", error: null };​export const fetchCart = createAsyncThunk("cart/fetch", async () => {  const response = await fetch("/api/cart");  if (!response.ok) throw new Error("Could not load cart.");  return (await response.json()) as CartItem[];});​const cartSlice = createSlice({  name: "cart",  initialState,  reducers: {},  extraReducers: (builder) => {    builder      .addCase(fetchCart.pending, (state) => {        state.status = "loading";        state.error = null;      })      .addCase(fetchCart.fulfilled, (state, action) => {        state.status = "idle";        state.items = action.payload;      })      .addCase(fetchCart.rejected, (state, action) => {        state.status = "failed";        state.error = action.error.message ?? "Something went wrong.";      });  },});​export default cartSlice.reducer;
React
// Cart.tsxexport default function Cart() {  const dispatch = useAppDispatch();  const { items, status, error } = useAppSelector((state) => state.cart);​  useEffect(() => {    dispatch(fetchCart());  }, [dispatch]);​  if (status === "loading") return <p>Loading...</p>;  if (error) return <p>{error}</p>;  return (    <ul>      {items.map((item) => (        <li key={item.id}>{item.title}</li>      ))}    </ul>  );}

The HTTP call lives in a service; the effect calls the service and dispatches the result.

Angular
// cart.service.ts@Injectable({ providedIn: "root" })export class CartService {  private http = inject(HttpClient);​  fetchCart() {    return this.http.get<CartItem[]>("/api/cart");  }}
Angular
// store/cart.actions.tsexport const CartActions = createActionGroup({  source: "Cart",  events: {    Load: emptyProps(),    "Load Success": props<{ items: CartItem[] }>(),    "Load Failure": props<{ error: string }>(),  },});
Angular
// store/cart.effects.tsimport { inject } from "@angular/core";import { Actions, createEffect, ofType } from "@ngrx/effects";import { catchError, exhaustMap, map, of } from "rxjs";​export const loadCart = createEffect(  (actions$ = inject(Actions), cartService = inject(CartService)) =>    actions$.pipe(      ofType(CartActions.load),      exhaustMap(() =>        cartService.fetchCart().pipe(          map((items) => CartActions.loadSuccess({ items })),          catchError(() => of(CartActions.loadFailure({ error: "Could not load cart." }))),        ),      ),    ),  { functional: true },);
Angular
// store/cart.feature.tsexport const cartFeature = createFeature({  name: "cart",  reducer: createReducer(    initialState,    on(CartActions.load, (state) => ({ ...state, status: "loading" as const, error: null })),    on(CartActions.loadSuccess, (state, { items }) => ({      ...state,      status: "idle" as const,      items,    })),    on(CartActions.loadFailure, (state, { error }) => ({      ...state,      status: "failed" as const,      error,    })),  ),});
Angular
// app.config.tsimport * as cartEffects from "./store/cart.effects";​providers: [  provideStore({ [cartFeature.name]: cartFeature.reducer }),  provideEffects(cartEffects),  provideHttpClient(),];
Angular
// cart.component.ts@Component({  selector: "app-cart",  template: `    @if (status() === "loading") {      <p>Loading...</p>    } @else if (error()) {      <p>{{ error() }}</p>    } @else {      <ul>        @for (item of items(); track item.id) {          <li>{{ item.title }}</li>        }      </ul>    }  `,})export class CartComponent {  private store = inject(Store);  protected items = this.store.selectSignal(cartFeature.selectItems);  protected status = this.store.selectSignal(cartFeature.selectStatus);  protected error = this.store.selectSignal(cartFeature.selectError);​  constructor() {    this.store.dispatch(CartActions.load());  }}

Differences and gotchas

  • Trigger: RTK components dispatch the thunk itself. NgRx components dispatch a plain action; the effect listens and does the work.
  • Lifecycle actions: RTK generates pending/fulfilled/rejected. NgRx declares Load/Load Success/Load Failure by hand.
  • Concurrency: NgRx picks the RxJS operator: exhaustMap (ignore while busy), switchMap (cancel previous), concatMap (queue). RTK thunks run every call unless you add a condition option.
  • RTK Query (createApi) is a higher-level option for data fetching with caching; it's the RTK counterpart of Angular's httpResource().

Quick reference

TaskRedux ToolkitNgRx
Async logiccreateAsyncThunk()createEffect()
Where HTTP livesThunk bodyService, called by effect
Handle resultsextraReducers + builder.addCaseon() in createReducer
Start itdispatch(fetchCart())store.dispatch(CartActions.load())
RegisterAutomatic in configureStoreprovideEffects()