Advanced Redux — Side Effects, Async Tasks & ThunksEntry 2 of 9
Side Effects in Components with useEffect
The component selects state from the store and runs a side effect in useEffect whenever that state changes.
react.devuseEffect
Notes
- Do the state change in the reducer first (e.g. add to cart), then sync it to the backend from a component.
- Selecting the cart with
useSelectorand listing it as an effect dependency means the effect runs every time the cart changes, keeping the backend up to date. - Problem: the effect also runs on the first render, when the cart is still empty. That sends the empty initial cart to the backend and overwrites any saved data.
- Fixes:
changedflag (recommended): reducers that represent user edits setstate.changed = true. The effect sends only whencart.changedis true. Loading data from the server doesn't set it.- Module-level
isInitialflag (course approach): a variable outside the component that skips the first run. Simpler but fragile (React StrictMode runs effects twice in development).
What / Why / When
- What
Syncing store state to a backend from a component.
- Why
Keeps reducers pure without extra Redux concepts.
- When
Small apps, or a single place where the side effect is triggered.
Code compare
// ❌ Course starting point: sends the empty cart on first renderfunction App() { const cart = useAppSelector((state) => state.cart); useEffect(() => { fetch("https://your-project.firebaseio.com/cart.json", { method: "PUT", body: JSON.stringify(cart), }); }, [cart]); return <Layout>{/* ... */}</Layout>;}// store/cart-slice.ts (excerpt)type CartState = { items: CartItem[]; totalQuantity: number; changed: boolean;};const cartSlice = createSlice({ name: "cart", initialState: { items: [], totalQuantity: 0, changed: false } as CartState, reducers: { replaceCart(state, action: PayloadAction<{ items: CartItem[]; totalQuantity: number }>) { state.items = action.payload.items; state.totalQuantity = action.payload.totalQuantity; // changed stays false: loading from the server is not a user edit }, addItem(state, action: PayloadAction<CartItem>) { state.changed = true; // ...add or increment the item }, removeItem(state, action: PayloadAction<string>) { state.changed = true; // ...decrement or remove the item }, },});// ✅ App.tsx: only send after a user edituseEffect(() => { if (!cart.changed) return; fetch("https://your-project.firebaseio.com/cart.json", { method: "PUT", body: JSON.stringify({ items: cart.items, totalQuantity: cart.totalQuantity }), });}, [cart]);The effect listens to the user actions that change the cart, not to the state itself, so the initial-load problem never happens.
// cart.service.ts@Injectable({ providedIn: "root" })export class CartService { private http = inject(HttpClient); private url = "https://your-project.firebaseio.com/cart.json"; saveCart(cart: { items: CartItem[]; totalQuantity: number }) { return this.http.put(this.url, cart); } fetchCart() { return this.http.get<{ items?: CartItem[]; totalQuantity?: number } | null>(this.url); }}// store/cart.effects.tsimport { inject } from "@angular/core";import { Store } from "@ngrx/store";import { Actions, createEffect, ofType } from "@ngrx/effects";import { concatLatestFrom } from "@ngrx/operators";import { catchError, map, of, switchMap } from "rxjs";export const saveCart = createEffect( (actions$ = inject(Actions), store = inject(Store), cartService = inject(CartService)) => actions$.pipe( ofType(CartActions.addItem, CartActions.removeItem), concatLatestFrom(() => store.select(cartFeature.selectCartState)), switchMap(([, cart]) => cartService.saveCart({ items: cart.items, totalQuantity: cart.totalQuantity }).pipe( map(() => UiActions.showNotification({ status: "success", title: "Success!", message: "Cart saved.", }), ), catchError(() => of( UiActions.showNotification({ status: "error", title: "Error!", message: "Sending cart data failed!", }), ), ), ), ), ), { functional: true },);Differences and gotchas
- React reacts to state changes (
[cart]dependency), so it needs a guard for the first render. NgRx reacts to actions, so loading data never triggers a save. concatLatestFrom(from@ngrx/operators) reads current store state inside an effect, the NgRx counterpart ofgetState()in a thunk.
Quick reference
| Task | React | Angular |
|---|---|---|
| Trigger | useEffect(..., [cart]) | ofType(addItem, removeItem) |
| Skip initial load | changed flag / isInitial | Not needed |
| Read state in side effect | useAppSelector | concatLatestFrom() |