Custom Fetch Wrapper

Using AbortController with Fetch API and ReactJS.

Pablo Garcia

--

There are multiple scenarios where we want to abort a fetch request but there are two most common that come to mind. First, to avoid duplicate calls with Incremental Search. And second, when a component or view is unmounted.

It is simple to implement an abortable fetch request, but to do so in a reusable way… Well, it is also amazingly simple!

The AbortController API works like this:

const controller = new AbortController()
fetch('some-url', { signal: controller.signal })
// somewhere else
controller.abort()

So simple, right? Well, the difficulty increases a bit when we try to make it reusable without having every component pass their own signal and keep track of them.

Barebones Solution

const ABORT_REQUEST_CONTROLLERS = new Map();export async function customFetch(
url,
{
signalKey, // Must be unique. If provided, the request will be abortable.
...rest
} = {}
) {
return await fetch(url, {
...(signalKey && { signal: abortAndGetSignalSafe(signalKey) }),
...rest
}).catch(error => {
if (error.name === 'AbortError') {
return new Response(JSON.stringify({}), {
status: 499, // Client Closed Request
statusText: error.message || 'Client Closed Request',
})
}
return new Response(JSON.stringify({}), {
status: 599, // Network Connect Timeout Error
statusText: error.message || 'Network Connect Timeout Error',
})
});
}
export function abortRequestSafe(key, reason = "CANCELLED") {
ABORT_REQUEST_CONTROLLERS.get(key)?.abort?.(reason);
}
function abortAndGetSignalSafe(key) {
abortRequestSafe(key); // abort previous request, if any
const newController = new AbortController();
ABORT_REQUEST_CONTROLLERS.set(key, newController);
return newController.signal;
}

How to use it?

The solution above allows us to pass a key instead of a signal, so you don’t have to keep track of the abort controllers yourself:

import { customFetch, abortRequestSafe } from '...'
customFetch('some-url', { signalKey: 'UNIQUE_ID' })
customFetch('some-url', { signalKey: 'UNIQUE_ID' }) // aborts first
abortRequestSafe('UNIQUE_ID', 'OPTIONAL_REASON') // manual abort

The key is; if you need to make the fetch request “abortable”, then you simply pass a unique signalKey which will be used to map to an AbortController. If you do not pass the signalKey, the request will behave like it normally does

import { customFetch } from '...'fetchUsers.abort =  () => {
abortRequestSafe('UNIQUE_KEY_FETCH_USERS')
}
async function fetchUsers() {
const response = await customFetch('api/users', {
method: 'POST',
signalKey: 'UNIQUE_KEY_FETCH_USERS' // ## THIS IS THE TRICK
})
// {
// status: response.status,
// statusText: response.statusText,
// headers: response.headers,
// url: response.url,
// body: await response.json(),
// }
if(response.status === 499) {
return [[], true] // aborted
}
return [await response.json(), false]
})
<input type="text" onChange={fetchUsers} />

How does it work?

The trick relies on having an internal Map of all the different AbortControllers so that we can abort them at any time manually using abortRequestSafe by passing the unique id (when a user clicks a button, etc) or automatically every time you call fetchRequest with the same unique ID because the previous AbortController will be aborted and replaced by a new controller using abortAndGetSignalSafe.

Power-up with React ComponentWillUnmount

We can use this useComponentWillUnmount react hook:

import { useComponentWillUnmount } from '...'
function Users() {
useComponentWillUnmount(() => {
abortRequestSafe('UNIQUE_ID_FETCH_USERS')
})
// ...
}

Polyfill

AbortController’s support is surprisingly good already, but if you are worried about those users that could be impacted, you can use abortcontroller-polyfill.

npm install --save abortcontroller-polyfill// usage
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'

Robust Solution

This solution below takes care of:

  1. stringifying the body
  2. consolidating the query parameters
  3. adds some defaults (credentials, and content-type)
  4. adds best practices (CSRF)
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'const ABORT_REQUEST_CONTROLLERS = new Map();export async function customFetch(
url,
{
method = "GET",
query,
body = {},
headers,
signalKey, // Must be unique. If provided, the request will be abortable.
_csrf = "",
...rest
} = {}
) {
/**
* According to the OWASP testing guide a CSRF token should not be contained within a GET request as the token itself might be logged in various places such as logs or because of the risk of shoulder surfing.
*/
const addCSRFTkn =
method === "POST" || method === "PUT" || method === "PATCH";
const addBody = method === "POST" || method === "PUT" || method === "PATCH";
return await fetch(getUrlPathWithQuery({ url, query }), {
method,
headers: {
// Tell backend we are sending JSON data
'Content-Type': 'application/json; charset=utf-8',
// Tell backend we expect a JSON response (ExpressJS: req.accepts('json'))
Accept: 'application/json; charset=utf-8',
// Tell backend we are using AJAX (ExpressJS: req.xhr = true|false)
'X-Requested-With': 'XMLHttpRequest',
// Prevent Cross-Site Request Forgery attacks.
...(addCSRFTkn && { "X-CSRF-Token": _csrf }),
...headers
},
credentials: "same-origin",
...(signalKey && { signal: abortAndGetSignalSafe(signalKey) }),
...(addBody && { body: JSON.stringify(body) }),
...rest
}).catch(handleNetworkError);
}
export function abortRequestSafe(key, reason = "CANCELLED") {
ABORT_REQUEST_CONTROLLERS.get(key)?.abort?.(reason);
}
function abortAndGetSignalSafe(key) {
abortRequestSafe(key); // abort previous request, if any
const newController = new AbortController();
ABORT_REQUEST_CONTROLLERS.set(key, newController);
return newController.signal;
}
// This makes sure to consolidate the url search params.
// E.g. url = "api/users?t=1" and query = { t: 2 }
// we would expect: "api/users?t=2"
function getUrlPathWithQuery({ url: partialUrl, query = {} } = {}) {
// The dummy origin is stripped out at the bottom.
const url = new URL(partialUrl, "https://dummy.origin");
const searchParams = new URLSearchParams({
...Object.fromEntries(url.searchParams),
...query
});
return `${url.pathname}?${searchParams}`;
}
/**
* The Promise returned from fetch() won’t reject on HTTP error status even if the response is an HTTP 404 or 500. Instead, it will resolve normally (with ok status set to false), and it will only reject on network failure or if anything prevented the request from completing.
* https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#differences_from_jquery
*/
function handleNetworkError(error) {
if (error.name === 'AbortError') {
return new Response(JSON.stringify({}), {
status: 499, // Client Closed Request
statusText: error.message || 'Client Closed Request',
})
}
return new Response(JSON.stringify({}), {
status: 599, // Network Connect Timeout Error
statusText: error.message || 'Network Connect Timeout Error',
})
}

Playground

--

--

Pablo Garcia

Staff Sofware Architect 2 at PayPal, M.S. in Computer Science w/specialization in Computing Systems, B.Eng. in Computer Software.