Clear useEffect side effects

In the React component, we will execute the method in useEffect() and return a function to eliminate its side effects. The following is a scenario in our business. This custom Hooks is 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;
}

🐚 problem

The problem with this method is that the execution time of fetchData() method is not considered. If its execution time exceeds 2s, it will cause the accumulation of polling tasks. In addition, it is also necessary to dynamically change the timing time, 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 the delay time is set after the last request is completed, it ensures that they will not accumulate. The following 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 lead to new problems. Because the next setTimeout execution needs to wait for fetchData() to complete. If we uninstall components when fetchData () is not finished, then clearTimeout() can only meaningless clear the callbacks at the current execution time, and the new delayed callback created by calling getList() after fetchData () will continue.

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 solve

🐋 Promise Effect

This problem is caused by the failure to cancel the subsequent undefined setTimeout() during Promise execution. So the first thought is that we should not record timeoutID directly, but record the Promise object of the whole logic upward. After Promise is executed, we can 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 scheme can solve the problem better, but promise task is still executing 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 a side effect for components and need to be "cleared". As long as promise tasks are cleared, subsequent processes will not be executed, so there will be no such problem.

Clearing Promise can be realized by AbortController at present. We execute controller in the unloading callback The abort () method finally makes the code go into the Reject logic and prevents 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("aborted", () => {
      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 flag

In the above scheme, our essence is to make asynchronous requests throw errors and interrupt the execution of subsequent codes. Is it OK for me to set a tag variable and execute subsequent logic only when the tag is in non unloading state? So the scheme came into being.

Defines an unmounted variable if it is marked as true in the unload callback. After the asynchronous task, if unmounted === true, the subsequent logic will not be followed to achieve similar effects.

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;
}

🎃 Postscript

The essence of the problem is how to eliminate the subsequent side effects after the component is unloaded during a long-time asynchronous task.

In fact, this is not limited to the Case in this article. We often write that there will be similar problems in the logic of requesting an interface in useEffect and updating the State after returning.

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

In addition, asynchronous requests are usually fast, so you won't notice this problem.

So do you have any other solutions to solve this problem? Welcome to comment~

Note: the drawings are from <How To Call Web APIs with the useEffect Hook in React>

Keywords: Javascript Front-end React hooks

Added by mgm_03 on Thu, 27 Jan 2022 20:36:29 +0200