-
Notifications
You must be signed in to change notification settings - Fork 13
Handling action side effects in Redux
Often we want to respond to the dispatch of an action in ways beyond simply updating the state which that action relates to- when information is changed in the state we may want to update other, related information, fetch some information from elsewhere, or perform other side effects outside of Redux such as adding information to local storage, or recording analytics data.
There are three main ways to handle action side effects with Redux: using thunks, adding extra reducers to a slice, or using listener middleware.
While thunks can be used to add almost any kind of behaviour to an action, this can quickly become a source of complexity and can make it hard to understand the intent of an action. Thunks are best used when the extra behaviour being added is entirely in service of the action that will eventually be dispatched- for example, fetching some data from the back end which will then be put into the state. Let's imagine we have the following slice that handles state relating to a postcode lookup:
type AddressOption = {
lineOne: string;
lineTwo: string;
city: string;
};
type PostcodeState = {
postcode: string;
lookupPending: boolean;
addressOptions: AddressOption[];
errorMessage?: string;
};
const initialState: PostcodeState = {
postcode: '',
lookupPending: false,
addressOptions: [],
};
export const postcodeSlice = createSlice({
name: 'postcode',
initialState,
reducers: {
setPostcode(state, action: PayloadAction<string>) {
state.postcode = action.payload;
},
},
});
We can use a thunk to add async behaviour that will manage the other state properties. With Redux Toolkit's built-in createAsyncThunk helper, we will automatically receive actions when the thunk has been called but the result is still pending, when it completes successfully, or if the promise rejects:
export const lookUpAddresses = createAsyncThunk(
'postcode/lookUpAddresses',
async (postcode: string) => {
const response = await fetch(`/postcodes/${postcode}`);
if (response.status !== 200) {
throw new Error('Request failed');
}
return (await response.json()) as AddressOption[];
},
);
export const postcodeSlice = createSlice({
name: 'postcode',
initialState,
reducers: {
setPostcode(state, action: PayloadAction<string>) {
state.postcode = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(lookUpAddresses.pending, (state) => {
state.lookupPending = true;
});
builder.addCase(lookUpAddresses.fulfilled, (state, action) => {
state.lookupPending = false;
state.addressOptions = action.payload;
});
builder.addCase(lookUpAddresses.rejected, (state) => {
state.lookupPending = false;
state.errorMessage = 'Sorry, something went wrong';
});
},
});
// elsewhere
store.dispatch(lookUpAddresses('N1 9GU'));
The thunk performs a side effect- making an HTTP request- but is ultimately only concerned with updating the state of the slice it belongs to.
The thunk example above demonstrates usage of the extraReducers
field passed to createSlice
, as does Writing state slices with Redux Toolkit. Adding extra reducers enables a slice to listen to and respond to actions besides the ones created from its reducers
field.
Like other slice reducers, extra reducers can only affect the state controlled by that slice, which limits them by default. They are useful when you need a slice's state to be changed in response to changes elsewhere in the store, and you do not need any reference to information outside of the slice's own state and the payload of the action that has been dispatched- for example, updating purchase price and/or currency based on a change to a user's delivery address.
Redux middleware, similar to middleware in server-side frameworks like Express, is a way of adding additional behaviour to the basic lifecycle of Redux in which actions are dispatched and passed into reducers. Thunks are enabled by middleware that check for dispatch
being passed a function instead of an action object, and Redux Toolkit also comes with middlewares enabled in dev mode that check for state mutations and non-serialisable values in the store.
Listener middleware is a highly flexible middleware that can listen for the dispatch of any action or set of actions, and perform any operation in response. It has access to the action itself, and to dispatch
and the full state of the form. For example, we might use it to save the payload of a specific action to session storage:
// In store setup
const listenerMiddleware = createListenerMiddleware();
export const startListening = listenerMiddleware.startListening;
// In listeners file
startListening({
actionCreator: setPaymentMethod,
effect(action) {
storage.setSession('selectedPaymentMethod', action.payload);
},
});
We can also use one of Redux Toolkit's matching utilities to listen for any of a set of actions:
const shouldCheckFormEnabled = isAnyOf(
setEmail,
setFirstName,
setLastName,
);
startListening({
matcher: shouldCheckFormEnabled,
effect(action, listenerApi) {
if (setEmail.match(action)) {
storage.setSession('gu.email', action.payload);
}
listenerApi.dispatch(enableOrDisableForm());
},
});
Listener middleware should be used when an action should have side effects that cannot be accomplished through extraReducers
- for example, where you need to refer to store data from more than one slice- and that do more than just ultimately updating the state- for example, when you need to add information to local/session storage, or interact with code outside of the React/Redux context.
As listener middleware sits outside of the core Redux structure and listener effects do not belong to slices, it can be dangerous to have them do too much complex work, as it's much less discoverable than thunks and extraReducers
.
- Wherever possible, use
extraReducers
. This keeps the side effect within the remit of the affected slice, and makes it clear when looking at that slice how it may change due to external factors. - If you need access to more of the store than
extraReducers
allows, or need to retrieve data from outside the store (eg. via a network request), but you are not affecting anything outside of the store, use a thunk, ideally with thecreateAsyncThunk
utility. - If you need an action to have side effects that go outside of the store, use listener middleware.
When the user is purchasing a Guardian Weekly subscription, and updates the country in their delivery address, we may need to update their fulfilment option in the product slice- whether we are able to fulfil the product via a more local printing partner or through international post depends on the country, and will affect the price of the subscription. This is handled through extraReducers
on the product slice, as we don't need any further information beyond the new delivery country to make the change.
We offer a postcode lookup feature for addresses in the UK, to make it a little faster to fill out the form. This involves making a fetch request to the back end with the user's postcode, and then updating the postcode slice with the returned list of possible addresses. If the request fails for some reason, we update the postcode slice with an appropriate error message. This is handled with a thunk associated with the postcode slice. We perform a side effect- making the fetch request- but ultimately we are only going to update the store regardless of if the request succeeds or fails.
On the contribution checkouts, when the user enters their email address we save it in session storage, so we still have it available if they go off-site to a payment provider like PayPal and then are returned to our thank you page, as we offer users the ability to sign up for various newsletters after checking out. This is handled with listener middleware, as it pertains to a change made outside of the store itself as a side-effect to changing the state.
- Redux Glossary
- Why Redux Toolkit?
- Writing state slices with Redux Toolkit
- Handling action side effects in Redux
- Presentational and Container Components
- Scoped actions and reducers
- Server Side Rendering
- Form validation
- CI build process
- Post deployment testing
- Post deployment test runbook
- TIP Real User Testing
- Code testing and validation
- Visual testing
- Testing Apple Pay locally
- Test Users
- Deploying to CODE
- Automated IT tests
- Deploying Fastly VCL Snippets
- Archived Components
- Authentication
- Switchboard
- How to make a fake contribution
- The epic and banner
- Environments
- Tech stack
- Supported browsers
- Contributions Internationalisation
- Payment method internationalisation in Guardian Weekly
- Print fulfilment/delivery
- Updating the acquisitions model
- Runscope testing
- Scala Steward for dependency management
- Alarm Investigations
- Ticker data
- Ophan
- Quantum Metric
- [Google Tag Manager] (https://github.com/guardian/support-frontend/wiki/Google-Tag-Manager)