Skip to content

Handling action side effects in Redux

Imogen Hardy edited this page Jul 26, 2022 · 4 revisions

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.

When to use a thunk

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.

When to use extraReducers

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.

When to use listener middleware

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.

Summary

  1. 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.
  2. 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 the createAsyncThunk utility.
  3. If you need an action to have side effects that go outside of the store, use listener middleware.

Some real-world examples from the support site

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.

πŸ™‹β€β™€οΈ General Information

🎨 Client-side 101

βš›οΈ React+Redux

πŸ’° Payment methods

πŸŽ› Deployment & Testing

πŸ“Š AB Testing

🚧 Helper Components

πŸ“š Other Reference

1️⃣ Quickstarts

πŸ›€οΈ Tracking

Clone this wiki locally