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 inaction.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
// 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;// 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.
// cart.service.ts@Injectable({ providedIn: "root" })export class CartService { private http = inject(HttpClient);​ fetchCart() { return this.http.get<CartItem[]>("/api/cart"); }}// store/cart.actions.tsexport const CartActions = createActionGroup({ source: "Cart", events: { Load: emptyProps(), "Load Success": props<{ items: CartItem[] }>(), "Load Failure": props<{ error: string }>(), },});// 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 },);// 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, })), ),});// app.config.tsimport * as cartEffects from "./store/cart.effects";​providers: [ provideStore({ [cartFeature.name]: cartFeature.reducer }), provideEffects(cartEffects), provideHttpClient(),];// 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 declaresLoad/Load Success/Load Failureby hand. - Concurrency: NgRx picks the RxJS operator:
exhaustMap(ignore while busy),switchMap(cancel previous),concatMap(queue). RTK thunks run every call unless you add aconditionoption. - RTK Query (
createApi) is a higher-level option for data fetching with caching; it's the RTK counterpart of Angular'shttpResource().
Quick reference
| Task | Redux Toolkit | NgRx |
|---|---|---|
| Async logic | createAsyncThunk() | createEffect() |
| Where HTTP lives | Thunk body | Service, called by effect |
| Handle results | extraReducers + builder.addCase | on() in createReducer |
| Start it | dispatch(fetchCart()) | store.dispatch(CartActions.load()) |
| Register | Automatic in configureStore | provideEffects() |