Asynchronous adventures: Aborting queries and mutations in react-apollo
If you just want to cancel a query or mutation in react-apollo, you can skip the intro and jump directly to the recipe.
📝 Editor’s note: this post was written in 2019 for v2 of React Apollo, so your mileage may vary if you’re using a newer version.
Why do I ever need to cancel a request in React Apollo?
Let’s take an interface that sends a bunch of consecutive requests where only the last one is the one that matters. It could be an autosuggest input, or a form with an automatic save on every change. To work correctly, an application has to use the last request’s response and ignore the previous results (even though the previous request may yield the result after the last one).
In a normal situation, react-apollo will do this for you automatically. For instance, imagine a field for a postal code on the ecommerce website. The contents are saved and checked automatically to determine whether or not shipping is possible to a given destination:
import * as React from "react";
import { Mutation } from "react-apollo";
import gql from 'graphql-tag';
const saveZipCode = gql`
mutation SaveZipCode($input: String) {
save(input: $input) {
hasShipping
}
}
`;
function ZipCodeField(props) {
return (
<Mutation mutation={saveZipCode}>
{(save, { data }) => (
<div>
<input
onChange={({ target: { value } }) =>
save({ variables: { input: value } })
}
/>
{data.hasShipping && <div>Shipping is available!</div>}
</div>
)}
</Mutation>
);
}
In the example above, every change of the input field will call the save mutation and receive the hasShipping
flag which indicates if shipping is available. Here, we want to ignore the results of all previous mutations which happened while a user was typing in the postal code.
Luckily, Apollo does this for us: if a <Mutation>
component has a previous mutation in progress, it will be automatically canceled as soon as a new one takes place.
Debounce mutation
Performing a mutation on every change is usually a bad idea since it puts extra load both on the network and on your backend. It’s better to debounce the user’s input and fire a request only after the user has stopped typing.
// There are plenty of 'debounce' implementations out there. We can use any of them.
import debounce from "lodash-es/debounce";
// ....
function ZipCodeField(props) {
const debouncedSave = React.useRef(
debounce((save, input) => save({ variables: { input } }), 500 )
);
return (
<Mutation mutation={saveZipCode}>
{(save, { data }) => (
<div>
<input
onChange={({ target: { value } }) => debouncedSave.current(save, value)}
/>
</div>
{data.hasShipping && <div>Shipping is available!</div>}
)}
</Mutation>
);
}
This code will postpone the save mutation for 500ms after the last change. Any intermediate changes will not fire a mutation at all.
However, this solution has a flaw. If an interval between two change events is slightly more than 500ms, both mutations will be fired, but Apollo won’t be able to cancel the first one for at least 500ms from the second debounce interval because the actual mutation has not been called yet. Here’s a possible timeline of events:
000ms: 1st onChange
—debounce mutation for 500ms.
500ms: the 1st mutation’s request is fired.
501ms: 2nd onChange
—debounce the second mutation for 500ms (Apollo doesn’t know about the second request and therefore, it can’t cancel the first one).
600ms: the 1st mutation’s response. Now the interface is updated with the result from the first mutation, but the input field has more text to send for the second mutation. Different parts of our interface are out of sync now.
1000ms: the 2nd mutation’s request is fired (it’s too late to cancel the 1st request).
Sometime in the future: the 2nd mutation response. Now the system gains consistency again.
There is a gap between the first and the second mutations’ responses and during this time our interface is out of sync. The input field has the postal code that was sent in the second mutation, but the interface shows the result of the previous postal code check. This may lead to an unpleasant UX or even some serious race condition bugs.
One of the best (and easiest) ways of fixing this is to manually cancel the first mutation immediately after the second onChange
event. Luckily, there is a way to do this in Apollo, although it’s not well documented.
Use AbortController API for Apollo requests cancellation
WARNING! According to this issue using abort controllers doesn’t work with GraphQL queries. It works for mutations but may have unexpected side effects in some configurations. There is a PR fixing this issue that is not merged yet.
In its standard configuration, Apollo uses the browser’s fetch
API for actual network requests, and it is possible to pass arbitrary options to it. So we can use Abort Signals to abort any mutation:
// Create abort controller
const controller = new window.AbortController();
// Fire mutation
save({ options: { context: { fetchOptions: { signal: controller.signal } } } });
// ...
// Abort mutation anytime later
controller.abort()
AbortController API is still in an experimental stage, so don’t forget to polyfill it if you care about old browser support.
Enhanced example with debouncing and aborting previous requests
With the help of abort signals, we can cancel an old request on every onChange
to make sure we will always show results only for the last one:
function ZipCodeField(props) {
const abortController = React.useRef();
const debouncedSave = React.useRef(
debounce((save, input) => {
const controller = new window.AbortController();
abortController.current = controller;
save({
variables: { input },
options: {
context: { fetchOptions: { signal: controller.signal } }
}
});
}, 500)
);
const abortLatest = () =>
abortController.current && abortController.current.abort();
return (
<Mutation mutation={saveZipCode}>
{(save, { data }) => (
<div>
<input
onChange={({ target: { value } }) => {
abortLatest();
debouncedSave.current(save, value);
}}
/>
{data.hasShipping && <div>Shipping is available!</div>}
</div>
)}
</Mutation>
);
}
Here we create an AbortController
for every mutation and save it to abortController
ref. Now we can manually cancel an ongoing mutation when the postal code is changed by calling abortController.current.abort()
For simple situations like this, a custom Apollo link might be the better option. But if you need fine-grained control over your requests, Abort Signals is a good way to achieve this.
Thank you for reading!