React is a JavaScript library open-sourced by Facebook that can be used to build UI on any platform. A common pattern in React is to use useEffect
with useState
to send requests, sync state from the API (outside React) to inside React, and use it to render UI, and this article shows you exactly why you shouldn’t do that directly.
TL; DR
- Most of the reasons for triggering network requests are user actions, and network requests should be sent in Event Handler.
- Most of the time, the data needed for the first screen can be rendered SSR-direct from the server, without sending additional network requests on the client side.
- Even if the client needs to fetch data on the first screen, in the future React and community-maintained libraries will provide Suspense-based data request patterns that implement “Render as your fetch”.
- Even when using the “Fetch on render” pattern, you should use a third-party library such as SWR or React Query directly instead of using
useEffect
.
Start by sending a simple request
Imagine you’re writing a React application that needs to get product listing data from the API and render it to the page. You think about the fact that network requests are not rendering, but side effects of rendering, and you think about the fact that React provides a special Hook useEffect
for handling side effects of rendering, most often in the scenario of synchronizing state that is external to React to React internally. Without thinking about it, you implement a <ProductList />
component.
|
|
You run npm run dev
and with a sense of accomplishment you see the list of products displayed on the page.
Show “Loading” and errors in UI
You find that when you first load, the page is white until the data is loaded, which is a bad user experience. So you decide to implement a “loading” progress bar and introduce a new state isLoading
.
|
|
Then you realize that in addition to a “loading” status, you need to display error alerts and report error logs if necessary, so you introduce a new status error
.
|
|
Wrapping a new Hook
You find it cumbersome to repeat the above code for every component that needs to fetch data from the API. So you decide to wrap it into a useFetch
Hook that you can call directly from within the component.
|
|
Now you can use the useFetch
Hook directly in the component.
Handling Race Condition
You implement a rotating component that switches between multiple products, with the currently displayed product stored in the state curentProduct
.
As a result, when you test it, you find that when you switch quickly in the rotating component, sometimes when you click on the next product, the interface shows the previous one.
This is because you didn’t declare how to clear your side effects in useEffect
. Sending a network request is an asynchronous behavior and the order in which the server data is received is not necessarily the order in which the network request is sent, Race Condition occurs.
If the second product’s data is returned faster than the first product as shown above, your data
will be overwritten by the first product’s data.
So you write a logic in useFetch
to clear the side effects.
|
|
Thanks to the power of JavaScript closures, Product 2 data will now not overwrite Product 2 data even if Product 2 data is returned before Product 1 data.
You can also check if the current browser supports AbortController
when clearing side effects, and use AbortSignal
to cancel aborted network requests.
|
|
Caching network requests
Let’s go back to the above rotating component.
Whenever the rotating component switches, <Product />
receives a new props.id
, the component undergoes an update, the url
changes, useEffect
is re-executed, and a new network request is triggered. To remove subsequent unnecessary network requests, useFetch
needs a cache.
|
|
Are you starting to get a bit of a headache? Don’t fret, we’re just getting started.
Cache Refresh
There are 2 hard problems in computer science: naming things, cache invalidation, and off-by-1 errors.
With caching, you need to refresh the cache, otherwise your data displayed on the UI may be out of date. There are many times when you can refresh the cache, for example you can refresh the cache when a tab loses Focus.
|
|
You can also update the cache regularly and repeatedly (interval), and you can update the cache when the user’s network state changes (when switching from data traffic to Wi-Fi). Now you need to write more useEffect
s and addEventListener
s.
Also, when the component is unmounted and remounted, you can first use the cache to render the interface to avoid another white screen, but then you need to asynchronously refresh the cache and finally update the latest data to the UI again.
Compatible with React 18 Concurrent Rendering
React 18 introduces the concept of Concurrent Rendering. In short, when opt-in Concurrent Rendering, React can interrupt, pause, or even abort updates marked as “low priority” (like Transitions) to make way for “high priority” updates.
When implementing the useFetch
cache, cache
is a global variable and every useFetch
in every component can read and write to cache
directly. Although the data obtained when cache.get
is up to date, after a useFetch
calls cache.set
, the cache
cannot notify other useFetch
s that it needs to be updated and has to passively wait for the next cache.get
from another useFetch
.
Suppose your <ProductList />
component uses React 18’s Concurrent API, such as useTransition
or startTransition
, and both <ProductList />
and <Carousel />
use useFetch(' https://dummyjson.com/products')
to get data from the same API. Since the <ProductList>
component opts in to Concurrent Rendering, rendering and updating of <ProductList />
and <Carousel />
may not necessarily happen at the same time (React may pause < ProductList />
in response to the user’s interaction with <Carousel />
, i.e. the updates of the two components are not synchronized), and the cache of useFetch
may have been refreshed and changed between the two updates, resulting in <ProductList />
and <Carousel />
using different cached data for their respective useFetch
s, resulting in Tearing.
To avoid Tearing, notify React of global variable updates, and schedule re-rendering, you need to re-implement cache
to use React 18’s other Hook useSyncExternalStore
.
|
|
Now, whenever a useFetch
is written to the cache
, React will update all components that used the useFetch
with the latest value in the cache
.
Feeling light-headed? Fasten your seat belt and let’s continue.
Request Merge De-duplication
There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors. Oh and weird concurrency bugs. Oh and weird concurrency bugs.
Your React app might look like this.
Since you are Fetching on render in the <Product />
component, there may be more than one <Product id={114514} />
in your React Tree at the same time; so you may still be sending more than one request to the same URL at the same time when the page is first loaded and not cached. To merge the same requests, you need to implement a mutex lock to avoid multiple useFetch
s sending multiple requests to the same URL; then you also need to implement a pub/sub to broadcast the API response data to all useFetch
s using this URL.
Do you think it’s over? No.
More
As a low-level React Hook for sending web requests, useFetch
will need to do more than that.
- Error Retry: Conditional retries when data loading goes wrong (e.g. retries on 5xx only, retries on 403, 404 abandoned)
- Preload: Preload data to avoid waterfall requests
- SSR, SSG: the data obtained by the server is used to fill the cache in advance, render the page, and then refresh the cache on the client side
- Pagination: large amount of data, paging requests
- Mutation: respond to user input, send data to the server
- Optimistic Mutation: Update local UI when user submits input, creating the illusion of “successful modification”, while sending input to the server asynchronously; if there is an error, the local UI needs to be rolled back.
- Middleware: Logging, error reporting, Authentication
With so many requirements for a useFetch
, why not just use an off-the-shelf React Data Fetching Hook? Both SWR and React Query can override these functions.
Reference
https://blog.skk.moe/post/why-you-should-not-fetch-data-directly-in-use-effect/