When developing applications, data fetching is one of the most fundamental tasks. Despite that, there are some things to watch out for: one of them is race conditions. This article explains what they are and provides a solution using the AbortController.
Identifying a race condition
A “race condition” is when our application depends on a sequence of events, but their order is uncontrollable. For example, this might occur with asynchronous code.
The term “race condition” dates to as far as 1954 and was first used in the field of electronics. A popular example of a race condition can be present in multithreading when multiple threads attempt to change shared data and race to access it first.
To visualize this, let’s use React and React router.
App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import React from 'react'; import { Routes, Route } from 'react-router-dom'; import Post from './Post'; function App() { return ( <Routes> <Route path="/posts/:postId" element={<Post />} /> </Routes> ); } export default App; |
Above, we define the /posts/:postId route and render the Post component.
Post.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import React from 'react'; import usePostLoading from './usePostLoading'; const Post = () => { const { post, isLoading } = usePostLoading(); if (!post || isLoading) { return <div>Loading...</div>; } return ( <div> <p>{post.id}</p> <p>{post.title}</p> <p>{post.body}</p> </div> ); }; export default Post; |
In the Post component, we either display a loading indicator or the fetched data. The most important part takes place in the usePostLoading hook.
usePostLoading.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import { useParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; interface Post { id: number; title: string; body: string; } function usePostLoading() { const { postId } = useParams<{ postId: string }>(); const [isLoading, setIsLoading] = useState(false); const [post, setPost] = useState<Post | null>(null); useEffect(() => { setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`) .then((response) => { if (response.ok) { return response.json(); } return Promise.reject(); }) .then((fetchedPost: Post) => { setPost(fetchedPost); }) .finally(() => { setIsLoading(false); }); }, [postId]); return { post, isLoading, }; } export default usePostLoading; |
If you want to know more about the useEffect hook, check out Understanding the useEffect hook in React. Designing custom hooks
Above, we fetch a post based on the URL. So, if the user visits /posts/1, we send a GET request to https://jsonplaceholder.typicode.com/posts/1.
Defining the race condition
The above approach is very common, but there is a catch. Let’s consider the following situation:
- The user opens
/posts/1 to see the first post,
- we start fetching the post with id 1,
- there are some Internet connection issues,
- the post does not load yet,
- Not waiting for the first post, the user changes the page to
/posts/2
- we start fetching the post with id 2,
- the post loads without issues and is available almost immediately,
- The first post finishes loading,
- the setPost(fetchedPost) line executes, overwriting the current state with the first post,
- even though the user switched the page to /posts/2, the first post is still visible.
Unfortunately, we can’t cancel a promise once we create it. There was a proposal to implement a feature like that, but it has been withdrawn.
The most straightforward fix for the above issue is introducing a didCancel variable, as suggested by Dan Abramov. When doing that, we need to use the fact that we can clean up after our useEffect hook. React will call it when our component unmounts if useEffect returns a function.
usePostLoading.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
useEffect(() => { let didCancel = false; setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`) .then((response) => { if (response.ok) { return response.json(); } return Promise.reject(); }) .then((fetchedPost: Post) => { if (!didCancel) { setPost(fetchedPost); } }) .finally(() => { setIsLoading(false); }); return () => { didCancel = true; } }, [postId]); |
Now, the bug we’ve seen before is no longer appearing:
- The user opens
/posts/1 to see the first post,
- we start fetching the post with id 1,
- Not waiting for the first post, the user changes the page to
/posts/2,
- the useEffect cleans after the previous post and sets didCancel to true,
- we start fetching the post with id 2,
- the post loads without issues and is available almost immediately,
- The first post finishes loading,
- the setPost(fetchedPost) line does not execute because of the didCancel variable.
Now, if the user changes the route from /posts/1 to /posts/2, we set didCancel to true. Thanks to that, if the promise resolves when we no longer need it, the setPost(fetchedPost) is not called.
Introducing AbortController
While the above solution fixes the problem, it is not optimal. The browser still waits for the HTTP request to finish but ignores its result. To improve this, we can use the AbortController.
With it, we can abort one or more fetch requests. To do this, we need to create an instance of the AbortController and use it when making the fetch request.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
useEffect(() => { const abortController = new AbortController(); setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { signal: abortController.signal, }) .then((response) => { if (response.ok) { return response.json(); } return Promise.reject(); }) .then((fetchedPost: Post) => { setPost(fetchedPost); }) .finally(() => { setIsLoading(false); }); return () => { abortController.abort(); }; }, [postId]); |
Above, we pass the abortController.signal through the fetch options. Thanks to that, the browser can stop the request when we call abortController.abort().
We can pass the same abortController.signal to multiple fetch requests. If we do that, abortController.abort() aborts multiple requests.
The above also fixes the issue we’ve had with the race conditions.
- The user opens
/posts/1 to see the first post,
- we start fetching the post with id 1,
- Not waiting for the first post, the user changes the page to
/posts/2,
- the useEffect cleans after the previous post and runs abortController.abort(),
- we start fetching the post with id 2,
- the post loads without issues and is available almost immediately,
- The first post never finishes loading because we’ve already aborted the first request.
We can observe the above behavior in the network tab in the developer tools.
A crucial thing about calling the abortController.abort() is that it causes the fetch promise to be rejected. It might result in an uncaught error.
To avoid the above message, let’s catch the error.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
useEffect(() => { const abortController = new AbortController(); setIsLoading(true); fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`, { signal: abortController.signal, }) .then((response) => { if (response.ok) { return response.json(); } return Promise.reject(); }) .then((fetchedPost: Post) => { setPost(fetchedPost); }) .catch(() => { if (abortController.signal.aborted) { console.log('The user aborted the request'); } else { console.error('The request failed'); } }) .finally(() => { setIsLoading(false); }); return () => { abortController.abort(); }; }, [postId]); |
Aborting other promises
Besides using the AbortController to cancel fetch requests, we can also use it in our functions. Let’s create a wait function that returns a promise to see this.
1 2 3 4 5 6 7 |
function wait(time: number) { return new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, time); }); } |
1 2 3 |
wait(5000).then(() => { console.log('5 seconds passed'); }); |
We can modify our wait function to accept a signal property similar to the fetch function. To do this, we need to use the fact that the signal emits an abort event when we call abortController.reject().
1 2 3 4 5 6 7 8 9 10 11 |
function wait(time: number, signal?: AbortSignal) { return new Promise<void>((resolve, reject) => { const timeoutId = setTimeout(() => { resolve(); }, time); signal?.addEventListener('abort', () => { clearTimeout(timeoutId); reject(); }); }); } |
Now, we need to pass the signal property to wait and abort the promise.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const abortController = new AbortController(); setTimeout(() => { abortController.abort(); }, 1000); wait(5000, abortController.signal) .then(() => { console.log('5 seconds passed'); }) .catch(() => { console.log('Waiting was interrupted'); }); |
Summary
In this article, we’ve gone through the problem of race conditions in React. To solve it, we’ve learned the idea behind the AbortController and expanded on the solution provided by Dan Abramov. Besides that, we’ve also learned how we can use the Abort Controller for other purposes. It required us to dig deeper and understand better how the AbortController works. Thanks to all of the above, we now know how to use the AbortController in various situations.
Thank you for your article! Today I have learned a new thing – AbortController.
Thanks for this incredible article
Thank you but can we do abort in onChange funtion?