DEV Community

Ivan Buryak for Evil Martians

Posted on • Updated on • Originally published at evilmartians.com

Asynchronous adventures: Aborting queries and mutations in react-apollo

TLDR: If you just want to cancel a query or mutation in react-apollo, you can skip an intro and jump directly to a recipe.

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 the only last one is the one that matters. It can be an autosuggest input, or a form with an automatic save on every change. To work correctly, an application have to use a response of the last request and ignore previous results (even though the previous request may yield the result after the last one).

In a normal situation, react-apollo will do it for you automatically. For instance, imagine a field for a postal code on the e-commerce web-site. Its contents are saved and automatically checked to determine whether 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 that tells if shipping is available. What we want is to ignore results of all previous mutations that happened while a user was typing in the code.

Luckily, Apollo does it for us: if <Mutation> component has a previous mutation in progress—it will be automatically canceled as soon as the new one takes place.

Debounce mutation

Performing a mutation on every change is usually a bad idea because it puts extra load both on a network and on your back-end. It is better to debounce 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.
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 saving 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 of the second debounce interval, because actual mutation has not been called yet. Here is the possible timeline of events:

000ms: 1st onChange—debounce mutation for 500ms.

500ms: fire 1st mutation's request.

501ms: 2nd onChange—debounce second mutation for 500ms (Apollo doesn’t know about a second request and therefore can not cancel the first one)

600ms: 1st mutation's response. Now interface is updated with the result of 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: fire 2nd mutation's request (it is too late to cancel 1st request)

Somewhere in the future: 2nd mutation response. Now the system gains consistency again

There is a gap between the first and the second mutations' responses, during which our interface is out of sync. Input field has a postal code that was sent in the second mutation but interface shows a result of the previous postal code's check. These may lead to the unpleasant UX, or even some serious race condition bugs.

One of the best (and easiest) ways of fixing it is to manually cancel the first mutation immediately after the second onChange event. Luckily, there is a way to do it in Apollo, although it is 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 browsers.

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 postal code is changed by calling abortController.current.abort()

For simple situations like this, custom Apollo link might be the better option. But if you need a fine grained control over your requests Abort Signals is a good way to achieve it.

Thank you for reading!


Read more dev articles on https://evilmartians.com/chronicles!

Top comments (1)

Collapse
 
jimbomaniak profile image
Oleg Kupriianov

Thanks for sharing, nice solution!