This article will discuss the common problem of page clutter caused by asynchronous requests in daily front-end development and summarize common solutions.

Looking directly at the example, suppose we are developing a query list page that manages the background. The page features a simple function that triggers the Table list to reload and render every time the search criteria change.

It’s easy to write a rough implementation (here’s React) :

const [dataSource, setDataSource] = useState([])
const [filters, setFilters] = useState({
  status: 0
})

const onFilterChange = useCallback((newValue) = > {
  setFilters(newValue)
}, [])

const fetchData = async (params) => {
   const { list } = await fetch('/foo/bar', {
      params
   })
   setDataSource(list)
}

useEffect(() = > {
  fetchData(filters)
}, [filters])

return (
  <>
    <Filters value={filters} onChange={onFilterChange} >
    <Table dataSource={dataSource} />
  </>
)
Copy the code

Filters are a controlled search component that accepts value, and onChange props. The fetchData method is triggered each time the filter criteria change, and the Table contents are updated after the asynchronous request ends.

Fetch in the example is not the fetch that comes with the browser. Usually in the project, we will wrap fetch with Response. json, error interception and other operations.

Such a query list requirement is so common that similar implementations can be found in almost every management back end system. However, this implementation is flawed.

The problem is that the developer is optimistic that the network request will go smoothly and that the last request will be completed before the data is pulled again.

And real user network condition is very complex, the user can open management system where 📶 very weak signal (such as in the wild on holiday when suddenly being required to provide some data by the boss), even if the user’s network is good, every link requests through are diverse and complex, each request cache, execution time, Even each server that serves the request has a different CPU load.

In short, it is problematic to be optimistic that all requests will return in order. For example, if the first request is answered after the second request is completed, the query conditions and the list display will be inconsistent.

Below, the search criteria is Status: inactive, and the filtered list item status is active.

Common solutions are as follows:

Method 1 Blocks user operations

Since the problem is caused by multiple requests, it is possible to think of a way to block from this point of view: if there is an outstanding request, the user is not allowed to initiate another request.

Using the management system query page as an example, we can put a disabled state on the filter to prevent a second request from occurring at the source.

const [loading, setLoading] = useState(false)

const fetchData = async (params) => {
   setLoading(true)
    try {
      const { list } = await fetch('/foo/bar', {
         params
      })
      setDataSource(list)
    } catch (error) {
    } finally {
      setLoading(false)}}/ /...

return (
  <>
    <Filters value={filters} onChange={onFilterChange} disabled={loading} >
    <Table dataSource={dataSource} loading={loading} />
  </>
)
Copy the code

When a fetchData request is initiated, loading is set to true and passed to the disabled property of the Filters so that the user is not allowed to modify the filter criteria. Change loading to false at the end of the request to allow the user to change the filtering criteria.

The disabled props of the Filters are given to each FormItem, so the details are not expanded here.

Blocking is a bad experience, and when a request is slow to respond to, the user can either continue to wait, or swipe the page and re-filter, either way is likely to cause discomfort and is not recommended (modern people are irritable 💥).

Method two throttle restriction

This problem is common in Input components, such as keyword searches in administrative pages, where it makes no sense to request a list of queries before the user has finished typing, so it is tempting to add debounce to fetchData as well.

Wait for a short period of time. If data needs to be pulled again due to other condition changes, the previous request is abandoned and the timer is reset. If data does not need to be pulled again, the request is initiated as planned.

const fetchData = useCallback(
  debounce(async (params) => {
    const { list } = await fetch('/foo/bar', {
       params
    })
    setDataSource(list)
  }, 300), [])Copy the code

For the useCallback, it will keep the fetchData method for every render using the same debmentioning fetch method, instead of creating a new method for every re-render.

This solution solved most of the problems quickly, but it did not completely avoid the occurrence of confusion, and there was an extra wait time for normal use.

Method 3 Version check

Combined with the first two methods, the best implementation of course is that the user operation is not blocked, can modify the filtering conditions, always the last operation results prevail, the previous request can be abandoned.

Build on this idea and add the concept of version. Add a version to each request, and when the asynchronous request completes, determine whether it is currently up to date. If not, discard the request result, and if so, apply the result.

const lastRequestVersionRef = useRef<number | null> (null)

const fetchData = async (params) => {
    const requestVersion = lastRequestVersionRef.current = Date.now()

    try {
      const { list } = await fetch('/foo/bar', {
         params
      })

      // If the requested version is the same as the latest version
      if (requestVersion === lastRequestVersionRef.current) {
         setDataSource(list)
      }
    } catch (error) {
    }
}
Copy the code

UseRef is used to record the version number of each request, in the example the version number is updated to the current timestamp before each request starts, and a temporary variable requestVersion is used as the version of the current request.

Determines when an asynchronous request is completed. If the version number of the current request is inconsistent with the latest version number, the request result is ignored.

You might think the requestVersion here will always equal lastRequestVersionRef. Current, actually lastRequestVersionRef might be new request update (in the process of another render). LastRequestVersionRef is a reference value, all requests are Shared this object, so the request read lastRequestVersionRef. At the end of the current would be the latest, not necessarily and former asynchronous requests to the value of the agreement.

Method four interrupts the request

The use of version control method, has been a good solution to the user experience and page confusion. But it doesn’t abort the network request, it just abandons it after it completes. It is wasteful to assume that each request server is spitting out a lot of data (such as returning an MP4 player) when it is not actually needed. Can I break the request?

Use the fetch method in React to cancel the request as in the above example:

const abortControllerRef = useRef(null)

const fetchData = async (params) => {
    try {
      const { list } = await fetch('/foo/bar', {
         params,
         signal: abortControllerRef.current.signal
      })

      setDataSource(list)
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('Fetch aborted');
      }
    }
}

useEffect(() = > {
  abortControllerRef.current = new AbortController()
  fetchData(filters)
  return () = > abortControllerRef.current.abort()
}, [filters])
Copy the code

In the example above, an AbortContorller is created before each rerequest and the abortController signal is passed to the fetch method. Pass the cancel method back to useEffect so that the normal flow does not occur each time the list is re-requested or the component is unmounted. This also avoids the warning of changing the state of an unmounted component.

Warning: Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.

For more information about AbortController, see the MDN documentation

Interrupt requests for other request methods:

XMLHttpRequest:

const xhr = new XMLHttpRequest()
xhr.open("GET"."/foo/bar")
xhr.send()

xhr.abort()
Copy the code

Axios:

 import { CancelToken } from 'axios'
 
 const source = CancelToken.source()

 axios.get('/foo/bar', {
   cancelToken: source.token,
 })
 
 source.abort()
Copy the code

conclusion

⚠️ is on short notice, and the code in the sample has not been rigorously tested, so it is only a reference example.

A link to the

  • Developers.google.com/web/updates…
  • Developer.mozilla.org/zh-CN/docs/…