Advanced Redux — Side Effects, Async Tasks & ThunksEntry 5 of 9
Writing Custom Thunk Action Creators
Hand-written thunks that run HTTP requests, dispatch notifications, and update slices, keeping components lean.
Notes
- Put thunks in a separate file next to the slice (e.g.
cart-actions.ts) to keep the slice file focused. - Typical flow: dispatch a pending notification → run the request → dispatch success (and the data) or error in
catch. replaceCartswaps in the fetched cart without settingchanged, so loading data doesn't trigger a save (Entry 2).- Components call
dispatch(fetchCartData())once on load anddispatch(sendCartData(cart))when the cart changes. - Type thunks with an
AppThunkhelper sodispatchandgetStateare typed.
What / Why / When
- What
Async action creators you write by hand.
- Why
Components no longer contain HTTP or notification logic.
- When
The flow is custom (several dispatches, notifications), or you're learning how thunks work before
createAsyncThunk.
Code compare
// store/index.ts (add the thunk type)import { configureStore, type ThunkAction, type UnknownAction } from "@reduxjs/toolkit";export const store = configureStore({ reducer: { ui: uiReducer, cart: cartReducer },});export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;export type AppThunk<R = Promise<void>> = ThunkAction<R, RootState, unknown, UnknownAction>;// store/cart-actions.tsimport type { AppThunk } from "./index";import { cartActions } from "./cart-slice";import { uiActions } from "./ui-slice";const CART_URL = "https://your-project.firebaseio.com/cart.json";export const fetchCartData = (): AppThunk => async (dispatch) => { async function fetchData() { const response = await fetch(CART_URL); if (!response.ok) throw new Error("Could not fetch cart data!"); return response.json(); } try { const cartData = await fetchData(); dispatch( cartActions.replaceCart({ items: cartData?.items ?? [], totalQuantity: cartData?.totalQuantity ?? 0, }), ); } catch { dispatch( uiActions.showNotification({ status: "error", title: "Error!", message: "Fetching cart data failed!", }), ); }};export const sendCartData = (cart: { items: CartItem[]; totalQuantity: number }): AppThunk => async (dispatch) => { dispatch( uiActions.showNotification({ status: "pending", title: "Sending...", message: "Sending cart data!", }), ); try { const response = await fetch(CART_URL, { method: "PUT", body: JSON.stringify(cart) }); if (!response.ok) throw new Error("Sending cart data failed."); dispatch( uiActions.showNotification({ status: "success", title: "Success!", message: "Sent cart data successfully!", }), ); } catch { dispatch( uiActions.showNotification({ status: "error", title: "Error!", message: "Sending cart data failed!", }), ); } };// App.tsxfunction App() { const dispatch = useAppDispatch(); const showCart = useAppSelector((state) => state.ui.cartIsVisible); const cart = useAppSelector((state) => state.cart); const notification = useAppSelector((state) => state.ui.notification); // Load on page load useEffect(() => { dispatch(fetchCartData()); }, [dispatch]); // Save after user edits useEffect(() => { if (cart.changed) { dispatch(sendCartData({ items: cart.items, totalQuantity: cart.totalQuantity })); } }, [cart, dispatch]); return ( <> {notification && <Notification {...notification} />} <Layout> {showCart && <Cart />} <Products /> </Layout> </> );}The fetch counterpart of fetchCartData. Saving is the saveCart effect from Entry 2.
// store/cart.effects.tsexport const loadCart = createEffect( (actions$ = inject(Actions), cartService = inject(CartService)) => actions$.pipe( ofType(CartActions.load), switchMap(() => cartService.fetchCart().pipe( map((data) => CartActions.replaceCart({ items: data?.items ?? [], totalQuantity: data?.totalQuantity ?? 0, }), ), catchError(() => of( UiActions.showNotification({ status: "error", title: "Error!", message: "Fetching cart data failed!", }), ), ), ), ), ), { functional: true },);// app.component.tsexport class AppComponent { private store = inject(Store); protected notification = this.store.selectSignal(uiFeature.selectNotification); protected showCart = this.store.selectSignal(uiFeature.selectCartIsVisible); constructor() { this.store.dispatch(CartActions.load()); // like dispatch(fetchCartData()) on load }}Differences and gotchas
- In the React version,
Appstill decides when to save (useEffect+changed). In NgRx, the effect decides by listening toaddItem/removeItem, so the component only dispatchesload. - Firebase returns
nullfor an empty cart. The?? []fallback preventsitemsfrom becomingundefined. - Pass
dispatchin the dependency array. Its identity is stable, so it doesn't cause extra runs.
Quick reference
| Task | React | Angular |
|---|---|---|
| Async action creator | (): AppThunk => async (dispatch) => {...} | createEffect() |
| Load on start | useEffect(() => dispatch(fetchCartData()), [dispatch]) | store.dispatch(CartActions.load()) in constructor |
| Error handling | try/catch + dispatch(uiActions...) | catchError → notification action |
| HTTP code | Inside the thunk | CartService |