'Do unfulfilled Promises cause memory leaks in React? [duplicate]

In a React app we are debouncing the keyboard input before performing a search similar to this simplified example:

function App() {
  const inputRef = useRef();
  const [result, setResult] = useState();

  const timeout = useRef();

  useEffect(() => () => clearTimeout(timeout.current));

  const debouncedSearch = useCallback((text) => {
    clearTimeout(timeout.current);
    const p = new Promise((resolve, reject) => {
      timeout.current = setTimeout(async () => {
        const result = await axios.get("/search?q=" + text);
        resolve(result);
      }, 1500);
    });
    return p;
  }, []);

  const onChange = useCallback(() => {
    const fn = async () => {
      const result = await debouncedSearch(inputRef.current.value);
      //if the promise isn't fulfilled will this hold onto setResult?
      setResult(result);
    };
    fn();
  }, []);

  return (
    <div>
      <input ref={inputRef} onChange={onChange} />
      <div>{result}</div>
    </div>
  );
}

As you can see for every letter typed, in onChange we are creating a new Promise and discarding the previous handler if called within 1.5 seconds.

Since the previous Promises are created but their state remains as unfulfilled, will they hold a hard reference to setResult which also holds a preference to previous render states, which leaks memory?



Solution 1:[1]

The code will continue to execute if the component is unmounted causing the setResult to be called with the fulfilled result which is a leak but if it isn't fulfilled (rejected?) then setResult will not be called and no leak should occur.

This is a very common problem that many people have found different work-arounds for but the result is what react has built-in warnings for.

"can't perform a react state update on an unmounted component"

My preferred approach to fixing this issue is using pub/sub to set async state. This allows you to unsubscribe on unmount thereby removing the issue.

Solution 2:[2]

You can make use of AbortController and a throw statement, along with async functions to reduce the number of execution contexts of your program. The throw statement will cause a rejection of the async function's returned promise, and awaiting that promise in another async function will convert the promise rejection into an exception, which you can catch using try...catch to prevent the exception from surfacing. This will allow all promises to settle, preventing potential related memory leaks.

In this example, I also used a mock function for your axios call, adjusted the return value for the example, and reduced the debouce delay (because 1500 is a very long time).

TS Playground

<div id="root"></div><script src="https://unpkg.com/[email protected]/umd/react.development.js"></script><script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script><script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script><script>Babel.registerPreset('tsx', {presets: [[Babel.availablePresets['typescript'], {allExtensions: true, isTSX: true}]]});</script>
<script type="text/babel" data-type="module" data-presets="tsx,react">

const {useCallback, useEffect, useRef, useState} = React;

function wait (delayMs: number) {
  return new Promise(res => setTimeout(res, delayMs));
}

async function mockAxios (...params: Parameters<(typeof axios)['get']>) {
  await wait(30);
  return {data: params[0]};
}

function Example () {
  const acRef = useRef(new AbortController());
  const [result, setResult] = useState<string>();
  useEffect(() => () => acRef.current.abort(), []);

  const debouncedSearch = useCallback(async (text: string, delayMs: number) => {
    acRef.current.abort();
    const ac = new AbortController();
    acRef.current = ac;

    await wait(delayMs);
    if (ac.signal.aborted) throw new Error('Aborted');
    const url = `/search?q=${text}`;
    const {signal} = ac;

    const result = await mockAxios(url, {signal});
    // const result = await axios.get(url, {signal});

    return result.data;
  }, []);

  const onChange = useCallback(async (ev: React.ChangeEvent<HTMLInputElement>) => {
    try {
      const result = await debouncedSearch(ev.target.value, 1000);
      setResult(result);
    }
    catch {/* Intentionally empty */}
  }, [debouncedSearch]);

  return (
    <div>
      <input {...{onChange}} placeholder="test by typing here" />
      <div>{result}</div>
    </div>
  );
}

ReactDOM.render(<Example />, document.getElementById('root'));

</script>

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Mark butterworth
Solution 2