12
头图

In the React component, we would execute the method in useEffect() and return a function to clean up its side effects. The following is a scenario in our business, the custom Hooks are used to call the interface to update data every 2s.

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    const id = setInterval(async () => {
      const data = await fetchData();
      setList(list => list.concat(data));
    }, 2000);
    return () => clearInterval(id);
  }, [fetchData]);

  return list;
}

🐚 Question

The problem with this method is that the execution time of the fetchData() method is not considered. If its execution time exceeds 2s, it will cause the accumulation of polling tasks. And there is also a need to make this timing dynamic in the future, and the server will issue the interval time to reduce the pressure on the server.

So here we can consider using setTimeout to replace setInterval . Since each time the delay time is set after the last request is completed, it ensures that they do not accumulate. Below is the modified code.

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    async function getList() {
      const data = await fetchData();
      setList(list => list.concat(data));
      id = setTimeout(getList, 2000);
    }
    getList();
    return () => clearTimeout(id);
  }, [fetchData]);

  return list;
}

However, changing to setTimeout will cause new problems. Because the next execution of setTimeout needs to wait for the completion of fetchData() before execution. If we uninstall the component before fetchData() is over, then clearTimeout() can only uselessly clear the current execution callback, and the new delayed callback created by calling fetchData() after getList() will continue to execute.

Online example: CodeSandbox

It can be seen that after clicking the button to hide the component, the number of interface requests continues to increase. So how to solve this problem? Several solutions are provided below.

🌟 How to fix

🐋 Promise Effect

The reason for this problem is that the setTimeout() that has not been defined subsequently cannot be cancelled during the execution of the Promise. So the first thought was that we should not record timeoutID directly, but should record the entire logical Promise object upwards. When the Promise is executed, we clear the timeout to ensure that we can clear the task exactly every time.

Online example: CodeSandbox

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let getListPromise;
    async function getList() {
      const data = await fetchData();
      setList((list) => list.concat(data));
      return setTimeout(() => {
        getListPromise = getList();
      }, 2000);
    }

    getListPromise = getList();
    return () => {
      getListPromise.then((id) => clearTimeout(id));
    };
  }, [fetchData]);
  return list;
}

🐳 AbortController

The above solution can solve the problem better, but the Promise task is still executed when the component is unloaded, which will cause a waste of resources. In fact, let's think about it another way. Promise asynchronous requests should also be side effects for components, and they also need to be "cleared". As long as the Promise task is cleared, the subsequent process will not be executed, so there will be no such problem.

Clearing Promise can currently be implemented using AbortController . By executing the controller.abort() method in the unload callback, we finally let the code go to the Reject logic and prevent subsequent code execution.

Online example: CodeSandbox

import { useState, useEffect } from 'react';

function fetchDataWithAbort({ fetchData, signal }) {
  if (signal.aborted) {
    return Promise.reject("aborted");
  }
  return new Promise((resolve, reject) => {
    fetchData().then(resolve, reject);
    signal.addEventListener("abort", () => {
      reject("aborted");
    });
  });
}
function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    const controller = new AbortController();
    async function getList() {
      try {
        const data = await fetchDataWithAbort({ fetchData, signal: controller.signal });
        setList(list => list.concat(data));
        id = setTimeout(getList, 2000);
      } catch(e) {
        console.error(e);
      }
    }
    getList();
    return () => {
      clearTimeout(id);
      controller.abort();
    };
  }, [fetchData]);

  return list;
}

🐬 Status flags

In the above scheme, our essence is to let the asynchronous request throw an error and interrupt the execution of subsequent code. Is it possible for me to set a tag variable so that the subsequent logic can be executed only when the tag is in a non-unloaded state? So the program came into being.

A unmounted variable is defined, if marked in the uninstall callback it is true . After the asynchronous task, it is judged that if it is unmounted === true , the subsequent logic will not be followed to achieve a similar effect.

Online example: CodeSandbox

import { useState, useEffect } from 'react';

export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    let id;
    let unmounted;
    async function getList() {
      const data = await fetchData();
      if(unmounted) {
        return;
      }

      setList(list => list.concat(data));
      id = setTimeout(getList, 2000);
    }
    getList();
    return () => {
      unmounted = true;
      clearTimeout(id);
    }
  }, [fetchData]);

  return list;
}

🎃 Afterword

The essence of the problem is how to clear subsequent side effects after the component is unloaded when a long-term asynchronous task is in the process.

In fact, this is not only limited to the Case of this article. We usually write to request the interface in useEffect , and the logic of updating the State after returning will also have similar problems.

Just because setState has no effect in an unmounted component, it is not perceived at the user level. And React will help us identify the scene. If the component is unloaded and then the setState operation is performed, there will be a Warning prompt.

In addition, asynchronous requests are generally faster, so everyone will not notice this problem.

So do you have any other solutions to this problem? Comments are welcome~

Note: title image is from "How To Call Web APIs with the useEffect Hook in React"

公子
36.6k 声望7.5k 粉丝

额米那个陀佛,无量那个天尊!