whiterook6
whiterook6

Reputation: 3534

How do I prevent a race condition in a react hook?

I have written a convenience hook for React that tracks whether a promise is running, whether there is an error, and what the results are. It's used like this:

const MyComponent = (props: IProps) => {
  const [promise, setPromise} = useState<Promise | undefined>();
  const {
    hasRun,
    isWaiting,
    results,
    error
  } = useService(promise);

  const doSomething = () => {
    setPromise(runSomeAsyncCode());
  };

  return (
    <div>...</div>
  );
};

It's not much more than a group of states that are updated as the promise starts, runs, succeeds, or fails:

export const useService = <T>(promise?: Promise<T>) => {
    const [hasRun, setHasRun] = useState<boolean>(false);
    const [isWaiting, setIsWaiting] = useState<boolean>(false);
    const [error, setError] = useState<Error | undefined>(undefined);
    const [results, setResults] = useState<T | undefined>();

    useEffect(() => {
        if (!promise) {
            return;
        }

        (async () => {
            if (!hasRun) {
                setHasRun(true);
            }
            setError(undefined);

            setIsWaiting(true);
            try {
                const r = await promise;
                setResults(r);
            } catch (err) {
                setResults(undefined);
                setError(err);
            } finally {
                setIsWaiting(false);
            }
        })();
    }, [promise]);

    return {
        error,
        hasRun,
        isWaiting,
        results,
    };
};

My problem is if the promise updates before a previous promise resolves, then the previous promise's results or error will still be returned to the component. For example, launching a couple AJAX requests, where the first fails after a minute but the second resolves in a couple seconds, leads to an initial success but then a reported failure a minute later.

How can I modify the hook so that it doesn't call setState for error or success if the promise has changed in the meantime?

Codepen: https://codepen.io/whiterook6/pen/gOPwJGq

Upvotes: 0

Views: 2198

Answers (1)

joshwilsonvu
joshwilsonvu

Reputation: 2679

Why not keep track of the current promise, and bail the effect if the promise has changed?

export const useService = <T>(promise?: Promise<T>) => {
    const [hasRun, setHasRun] = useState<boolean>(false);
    const [isWaiting, setIsWaiting] = useState<boolean>(false);
    const [error, setError] = useState<Error | undefined>(undefined);
    const [results, setResults] = useState<T | undefined>();

    const promiseRef = useRef(promise);
    promiseRef.current = promise; // ** keep ref always up to date

    useEffect(() => {
        if (!promise) {
            return;
        }

        (async () => {
            setHasRun(true);
            setError(undefined);

            setIsWaiting(true);
            try {
                const r = await promise;
                if (promiseRef.current !== promise) {
                    return;
                }
                setResults(r);
                setIsWaiting(false);
            } catch (err) {
                if (promiseRef.current !== promise) {
                    return;
                }
                setResults(undefined);
                setError(err);
                setIsWaiting(false);
            }
        })();

        // you may want to reset states when the promise changes
        return () => {
            setHasRun(false);
            setIsWaiting(false);
            setError(undefined);
            setResults(undefined);
        }
    }, [promise]);

    return {
        error,
        hasRun,
        isWaiting,
        results,
    };
};

useRef isn't just for DOM element refs, as the docs point out.

Essentially, useRef is like a “box” that can hold a mutable value in its .current property. [...] Mutating the .current property doesn’t cause a re-render.

The reason I used useRef here is because we need a mutable value that can hold the latest promise argument without causing a rerender. Because promiseRef never changes (only .current does), you can assign the latest value on the line with ** and access it in the async function, even after time has passed and the component has rerendered.

Upvotes: 2

Related Questions