Cancelling fetch in React and TypeScript
This post will cover how to programmatically cancel a fetch request in a React and TypeScript app.
A React component
We have a typical React component that fetches some data from a web API and renders it:
export function App() {
const [status, setStatus] = React.useState<"loading" | "loaded" | "cancelled">("loading");
const [data, setData] = React.useState<Character | undefined>(undefined);
React.useEffect(() => {
getCharacter(1).then((character) => {
setData(character);
setStatus("loaded");
});
}, []);
if (status === "loading") {
return (
<div>
<div>loading ...</div>
<button>Cancel</button>
</div>
);
}
if (status === "cancelled") {
return <div>Cancelled</div>;
}
return <div>{data && <h3>{data.name}</h3>}</div>;
}
The data is fetched inside the getCharacter
function inside a useEffect
and put in state called data
.
A state variable called status
tracks where we are in the fetching process. Notice that a Cancel button is being rendered when the data is being fetched.
When the Cancel button is clicked, we want to cancel the fetch request.
Let’s have a look at the getCharacter
function:
async function getCharacter(id: number) {
const response = await fetch(`https://swapi.dev/api/people/${id}/`);
const data = await response.json();
assertIsCharacter(data);
return data;
}
It’s a straightforward request to the Star Wars API.
Here’s the Character
type:
type Character = {
name: string;
};
We are only interested in the name
field from the Star Wars people resource.
The assertIsCharacter
type assert function is as follows:
function assertIsCharacter(data: any): asserts data is Character {
if (!("name" in data)) {
throw new Error("Not character");
}
}
This type assert function allows TypeScript to narrow the type of data
to Character
.
Using AbortController
to cancel fetch
AbortController
is a fairly recent addition to JavaScript which came after the initial fetch
implementation. The good news is that it is supported in all modern browsers.
AbortController
contains an abort
method. It also contains a signal
property that can be passed to fetch
. When AbortController.abort
is called, the fetch
request is cancelled.
Let’s use AbortController
and its signal
in the fetch
request in getCharacter
:
function getCharacter(id: number) {
const controller = new AbortController();
const signal = controller.signal;
const promise = new Promise(async (resolve) => {
const response = await fetch(`https://swapi.dev/api/people/${id}/`, {
method: "get",
signal,
});
const data = await response.json();
assertIsCharacter(data);
resolve(data);
});
promise.cancel = () => controller.abort();
return promise;
}
We have removed the async
keyword from getCharacter
and wrapped the existing code in a new Promise
. The Promise
is resolved with the data from the request. We’ve added a cancel
method to the Promise
, which calls AbortController.abort
.
The Promise
containing the cancel
method is returned from getCharacter
so that the calling code can use this to cancel the request.
We have a type error though:
// 💥 - Property 'cancel' does not exist on type 'Promise<unknown>'
promise.cancel = () => controller.abort();
Let’s create a new type for the Promise containing the cancel
method:
interface PromiseWithCancel<T> extends Promise<T> {
cancel: () => void;
}
We can then use a type assertion to resolve the type error:
function getCharacter(id: number) {
...
(promise as PromiseWithCancel<Character>).cancel = () => controller.abort(); return promise as PromiseWithCancel<Character>;}
Using the new getCharacter
in the React component
We are going to store the promise from getCharacter
in a state variable called query
.
export function App() {
const [status, setStatus] = React.useState<"loading" | "loaded" | "cancelled">("loading");
const [data, setData] = React.useState<Character | undefined>(undefined);
const [query, setQuery] = React.useState<PromiseWithCancel<Character> | undefined>(undefined); React.useEffect(() => {
const q = getCharacter(1); setQuery(q); q.then((character) => { setData(character);
setStatus("loaded");
});
}, []);
...
We can now call the cancel
method in the promise when the Cancel button is clicked:
<button
onClick={() => { query?.cancel(); setStatus("cancelled"); }}>
Cancel
</button>
When the Cancel button is clicked, we see that Cancelled is rendered:
If we look at the network requests, we can see the request was cancelled:
Nice. 😊
Catching the abort error
If we look at the console, we see that an error was raised when the request was cancelled:
We can catch this error by wrapping the request in a try
catch
statement:
const promise = new Promise(async (resolve) => {
try { const response = await fetch(`https://swapi.dev/api/people/${id}/`, {
method: "get",
signal,
});
const data = await response.json();
assertIsCharacter(data);
resolve(data);
} catch (ex: unknown) { if (isAbortError(ex)) { console.log(ex.message); } }});
The isAbortError
type predicate function is as follows:
function isAbortError(error: any): error is DOMException {
if (error && error.name === "AbortError") {
return true;
}
return false;
}
Now when we click the Cancel button, we get the message output to the console rather than the error:
Wrap up
The signal
property in AbortController
can be passed into fetch
. AbortController.abort
can be then called to cancel the request.
Cancelling fetch
raises an error that can be swallowed using try
catch
.
The complete code is in this gist.
Did you find this post useful?
Let me know by sharing it on Twitter.If you to learn more about using TypeScript with React, you may find my course useful: