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.
  • replaceCart swaps in the fetched cart without setting changed, so loading data doesn't trigger a save (Entry 2).
  • Components call dispatch(fetchCartData()) once on load and dispatch(sendCartData(cart)) when the cart changes.
  • Type thunks with an AppThunk helper so dispatch and getState are 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

React
// 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>;
React
// 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!",        }),      );    }  };
React
// 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.

Angular
// 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 },);
Angular
// 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, App still decides when to save (useEffect + changed). In NgRx, the effect decides by listening to addItem/removeItem, so the component only dispatches load.
  • Firebase returns null for an empty cart. The ?? [] fallback prevents items from becoming undefined.
  • Pass dispatch in the dependency array. Its identity is stable, so it doesn't cause extra runs.

Quick reference

TaskReactAngular
Async action creator(): AppThunk => async (dispatch) => {...}createEffect()
Load on startuseEffect(() => dispatch(fetchCartData()), [dispatch])store.dispatch(CartActions.load()) in constructor
Error handlingtry/catch + dispatch(uiActions...)catchError → notification action
HTTP codeInside the thunkCartService