Dachang Technology insists on selecting good articles for Zhou Geng
| Introduction The term race conditions is translated from English "race conditions". When we are developing the front-end web, the most common logic is to obtain and process data from the backend server and then render it to the browser page. There are many details in the process that need to be paid attention to, one of which is the data race condition. This article will be based on React combined with a small demo to explain what a race condition is, as well as a step-by-step introduction to solving the race condition. Different frameworks solve the problem in different ways, but it does not affect the understanding of race conditions.
retrieve data
The following is a small demo: the front end gets the article data and renders it on the page
App.tsx
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Article from './Article';
function App() {
return (
<Routes>
<Route path="/articles/:articleId" element={<Article />} />
</Routes>
);
}
export default App;
Article.tsx
import React from 'react';
import useArticleLoading from './useArticleLoading';
const Article = () => {
const { article, isLoading } = useArticleLoading();
if (!article || isLoading) {
return<div>Loading...</div>;
}
return (
<div>
<p>{article.id}</p>
<p>{article.title}</p>
<p>{article.body}</p>
</div>
);
};
export default Article;
In the above-mentioned Article component, we encapsulate the relevant data request into the custom hook "useArticleLoading". For the user experience of the page, we either display the acquired data or display the loading. The loading state judgment is added here.
useArticleLoading.tsx
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
interface Article {
id: number;
title: string;
body: string;
}
function useArticleLoading() {
const { articleId } = useParams<{ articleId: string }>();
const [isLoading, setIsLoading] = useState(false);
const [article, setArticle] = useState<Article | null>(null);
useEffect(() => {
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`)
.then((response) => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.finally(() => {
setIsLoading(false);
});
}, [articleId]);
return {
article,
isLoading,
};
}
export default useArticleLoading;
In this custom hook, we manage the loading state and data request
When our url accesses /articles/1, a get request will be issued to obtain the content of the article whose corresponding articleId is 1
Race condition occurs
The above is our very common way of getting data, but let's consider the following case (chronological order):
-
Visit articles/1 to view the first article content
-
The browser starts to request the background server to get the content of article 1
-
There is a problem with the network connection
-
articles/1 request is not responding, data is not rendered to the page
-
Don't wait for articles/1, visit articles/2
-
The browser starts to request the background server to get the content of article 2
-
No problem with internet connection
-
The articles/2 request is responded immediately, and the data is rendered to the page
-
The request for articles/1 responded
-
Overrides the current article content via setArticles (fetchedArticles)
-
The current url should show articles/2, but it shows articles/1
One thing to understand is that the network request process is complex, and the response time is uncertain. When accessing the same destination address, the network links that the request passes through are not necessarily the same path. Therefore, the first request is not necessarily the first response. If the front end is developed with the first request first response rule, it may lead to wrong data usage, which is a race condition problem.
solve
The solution is also very simple. When the response is received, it is only necessary to judge whether the current data is needed, and if not, ignore it.
In React, this can be done neatly and conveniently through the execution mechanism of useEffect:
useArticlesLoading.tsx
useEffect(() => {
let didCancel = false;
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`)
.then((response) => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
if (!didCancel) {
setArticle(fetchedArticle);
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
didCancel = true;
}
}, [articleId]);
According to the execution mechanism of the hook: every time you switch to get a new article, execute the function returned by useEffect, and then re-execute the hook and re-render.
Now the bug doesn't appear anymore:
-
Visit articles/1 to view the first article content
-
The browser starts to request the background server to get the content of article 1
-
There is a problem with the network connection
-
articles/1 request is not responding, data is not rendered to the page
-
Don't wait for articles/1, visit articles/2
-
useArticleLoading re-renders and executes the last useEffect return function before re-rendering, and sets didCancel to true
-
No problem with internet connection
-
The articles/2 request is responded immediately, and the data is rendered to the page
-
The request for articles/1 responded
-
setArticles(fetchedArticles) did not execute due to didCancel variable.
After processing, when we switch the article again, didCancel is true, and the data of the previous article and setArticles will not be processed again.
AbortController solution
While the above solution by variables solves the problem, it is not optimal. The browser still waits for the request to complete, but ignores its result. This is still a waste of resources. To improve this we can use AbortController.
Through AbortController, we can abort one or more requests. Usage is simple, create an instance of AbortController and use it when making a request:
useEffect(() => {
const abortController = new AbortController();
setIsLoading(true);
fetch(`https://get.a.rticle.com/articles/${articleId}`, {
signal: abortController.signal,
})
.then((response) => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.finally(() => {
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, [articleId]);
By passing abortController.signal, we can easily use abortController.abort() to abort the request (or pass the same signal to multiple requests, which can terminate multiple requests)
After using abortController, let's take a look at the effect:
-
Visit articles/1
-
Request the server to get the articles/1 data
-
Visit articles/2 without waiting for a response
-
Re-render the hook, useEffect executes the return function, executes abortController.abort ()
-
Request the server to get articles/2 data
-
Get the articles/2 data and render it on the page
-
The first article never finished loading because we killed the request manually
Manually interrupted requests can be viewed in the dev tools:
A problem with calling abortController.abort() is that it causes the promise to be rejected, possibly resulting in an uncaught error:
To avoid this, we can add a catch error handler:
useEffect(() => {
const abortController = new AbortController();
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`, {
signal: abortController.signal,
})
.then((response) => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.catch(() => {
if (abortController.signal.aborted) {
console.log('The user aborted the request');
} else {
console.error('The request failed');
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, [articleId]);
stop other promises
AbortController can not only stop asynchronous requests, it can also be used in functions:
function wait(time: number) {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, time);
});
}
wait(5000).then(() => {
console.log('5 seconds passed');
});
function wait(time: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve();
}, time);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject();
});
});
}
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, 1000);
wait(5000, abortController.signal)
.then(() => {
console.log('5 seconds passed');
})
.catch(() => {
console.log('Waiting was interrupted');
});
Pass the signal to wait to terminate the promise.
other
About AbortController compatibility:
Except for IE, others can be used with confidence.
Summarize
This article discusses race conditions in React and explains the race condition issue. To solve this problem, we learned the idea behind AbortController and extended the solution. In addition to this, we also learned how to use AbortController for other purposes. It requires us to dig deeper and better understand how AbortController works. For the front end, you can choose your own most suitable solution.