Redux reducers should be pure functions. This means that, for the same input state and action, a reducer should always produce the same output.
Reducers should not perform side effects such as:
- making HTTP requests
- starting timers
- writing to local storage
- showing notifications
- changing external state
Instead, those side effects should be handled in places such as components, custom hooks, middleware, or asynchronous thunks.
1) Keeping reducers pure
A reducer receives the previous state and an action, then returns the next state.
const cartReducer = (state, action) => {
switch (action.type) {
case 'addToCart':
return {
...state,
items: [...state.items, action.payload],
};
default:
return state;
}
};
This reducer does not call the network or modify any external resource. It only calculates a new state object.
2) Handling side effects with useEffect
A component can watch a selected value with useSelector and run a side effect when that value changes.
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { cartActions } from './cartSlice';
export default function CartPage() {
const cart = useSelector((state) => state.cartReducer.cart);
const dispatch = useDispatch();
useEffect(() => {
const saveCart = async () => {
try {
await fetch('https://api.example.com/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cart),
});
} catch (error) {
console.error('Unable to save the cart', error);
}
};
saveCart();
}, [cart]);
return <div>Cart items: {cart.length}</div>;
}
Here, the useEffect is responsible for the HTTP request. The reducer remains responsible only for updating the Redux state.
A critical detail is that this effect may run after every change to cart. The backend request should be designed carefully to avoid unnecessary requests, especially when the component is mounted, updated, or unmounted.
3) How createAsyncThunk uses dispatch
createAsyncThunk does not run the HTTP request directly from the component. The component dispatches a thunk function, and Redux Toolkit’s thunk middleware executes it.
The flow is:
- The component calls
dispatch(saveCart(cart)). - The thunk middleware sees that
saveCart(cart)returns a function. - The middleware runs that function and gives it
dispatch,getState, and other thunk arguments. -
createAsyncThunkdispatchescart/saveCart/pendingbefore running the payload creator. - The payload creator performs the HTTP request.
-
createAsyncThunkdispatches eithercart/saveCart/fulfilledorcart/saveCart/rejected.
The thunk itself is commonly created as follows:
import { createAsyncThunk } from '@reduxjs/toolkit';
export const saveCart = createAsyncThunk(
'cart/saveCart',
async (cart, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cart),
});
if (!response.ok) {
return rejectWithValue('Unable to save the cart');
}
return await response.json();
} catch (error) {
return rejectWithValue(
error instanceof Error ? error.message : 'Unknown error'
);
}
}
);
Calling the thunk action creator returns a function:
const thunk = saveCart(cart);
That returned function is what the component dispatches:
import { useDispatch } from 'react-redux';
import { saveCart } from './cartThunks';
const dispatch = useDispatch();
const handleSave = () => {
dispatch(saveCart(cart));
};
The important distinction is that the component does not call dispatch for every lifecycle state. It dispatches the thunk once. The thunk middleware then performs the work and emits the lifecycle actions for it.
How the reducer handles those actions
The reducer can update state based on the three lifecycle actions:
const cartSlice = createSlice({
name: 'cart',
initialState: {
status: 'idle',
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(saveCart.pending, (state) => {
state.status = 'saving';
state.error = null;
})
.addCase(saveCart.fulfilled, (state) => {
state.status = 'saved';
})
.addCase(saveCart.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
The action flow is therefore:
component
-> dispatch(saveCart(cart))
-> thunk middleware executes saveCart(cart)
-> dispatch(cart/saveCart/pending)
-> payload creator performs fetch
-> dispatch(cart/saveCart/fulfilled or cart/saveCart/rejected)
-> reducer updates the status
4) Difference between action creators and thunks
A normal action creator should return a plain action object:
const increment = (amount: number) => ({
type: 'counter/increment',
payload: amount,
});
A thunk is a function that can call dispatch and getState. In the createAsyncThunk case, the generated thunk function receives those APIs from the thunk middleware:
const thunk = saveCart(cart);
// The thunk middleware calls this function internally:
// thunk(dispatch, getState, extraArgument)
The important distinction is:
- action creators keep their result deterministic
- thunks can perform asynchronous work and dispatch lifecycle actions afterward
5) Using a thunk inside useEffect
A thunk is often dispatched from a component effect when data must be saved after the Redux state changes.
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCart } from './cartThunks';
export default function CartPage() {
const cart = useSelector((state) => state.cartReducer.cart);
const dispatch = useDispatch();
useEffect(() => {
if (cart.length === 0) {
return;
}
dispatch(saveCart(cart));
}, [cart, dispatch]);
return <div>Cart</div>;
}
The component does not directly perform the HTTP request. It dispatches a thunk, and the thunk performs the asynchronous operation.
6) Summary
- reducers must remain pure
- side effects should be handled outside reducers
-
useEffectcan observe selected state and perform a side effect - thunks are functions that execute asynchronously and dispatch actions
-
createAsyncThunkis the recommended Redux Toolkit way to create asynchronous thunks - action creators should return plain actions, while thunks can perform additional work
The main architectural rule is: reducers transform state, while side-effect systems manage network requests, timers, and other external work.
Top comments (0)