State values passed in as arguments in useEffect not being read in reactjs

Viewed 45

I am trying to build a cryptocurrency application. But I am having trouble getting populated state values that are passed into the useEffect as parameters to a debouncing/polling function.

The issue is that the debouncing works well, as in it detects the value and calls the api after the 500ms that I specified in debounce. However, the polling portion seems to not have the state values of of transactionType, fromCurrencyAmount, and pair. It seems like after I debounce the input, after 6 seconds the polling will do its thing but the values passed in the params are undefined. Is there anyway I can solve this?

Here is the method that serves two purposes. It has an api to be polled from every 6 seconds, as well as getting debounced input if the user enters an amount inside the input.

  function handleInitPoll(baseAndQuote, side, value) {
    getSwapPrice(baseAndQuote, side, value || 0)
      .then((res) => {
        if (!res.price) {
          setIsLoading(true);
        } else if (res.error) {
          setErrorMessage(res.error);
        } else if (res.price) {
          setIsLoading(false);
          setSwapPriceInfo(res);
        }
      });
  }

And here is the useEffect:

  useEffect(() => {

      handleInitPoll(pair, transactionType, fromCurrencyAmount);

      const timer = setInterval(handleInitPoll, 6000, pair, transactionType, fromCurrencyAmount);
      return () => {
        clearInterval(timer);
      };
    }
    setSelectedCurrencyState({ ...selectedCurrencyState, selectedFromCurrency: 'USDT', selectedToCurrency: 'XLM' });
  }, [pair, transactionType, fromCurrencyAmount]);

And here is the debounce declaration:

  const debounceOnChange = useCallback(debounce(handleInitPoll, 500, pair, transactionType, fromCurrencyAmount), []);

And here is where the debouncing is being done, which is inside an onChange handler:

  const handleAssetAmount = (e) => {
    const { value } = e.target;
    const formattedAmount = handleAssetAmountFormat(value);
    setFromCurrencyAmount(formattedAmount);
    validateInputAmount(formattedAmount);
    debounceOnChange(pair, transactionType, formattedAmount);
  };
2 Answers

Issue

The issue here is that you've closed over stale values in the interval callback.

Solution

One solution is to cache these state values in a React ref such that the current value can be accessed in the polling function.

Example:

const pairRef = React.useRef(pair);
const transactionTypeRef = React.useRef(transactionType);
const fromCurrencyAmountRef = React.useRef(fromCurrencyAmount);

useEffect(() => {
  pairRef.current = pair;
}, [pair]);

useEffect(() => {
  transactionTypeRef.current = transactionTypeRef;
}, [transactionType]);

useEffect(() => {
  fromCurrencyAmountRef.current = fromCurrencyAmount;
}, [fromCurrencyAmount]);

useEffect(() => {
  handleInitPoll(pair, transactionType, fromCurrencyAmount);

  const timer = setInterval(() => {
    handleInitPoll(
      pairRef.current,
      transactionTypeRef.current,
      fromCurrencyAmountRef.current
    );
  }, 6000);

  return () => {
    clearInterval(timer);
  };
}, [pair, transactionType, fromCurrencyAmount]);

Fundamentally, your code seems to be correct with a few issues:

  1. There is a race condition.

    If getSwapPrice is running and the component is updated, it can still affect the state when setSwapPriceInfo or setLoading are called when the promise is resolved.

    This is particularly bad, because network requests can "overtake" each other. Thus it can happen that the return value of getSwapPrice updates the component with the result of an old network request.

    This is discussed in this article.

  2. There is this odd call to setSelectedCurrencyState in the useEffect block. It's not clear what this is supposed to do, but it clearly doesn't belong there.

However, the underlying application should work fine, I reproduced it with a simpler application here:

import { useEffect, useState } from "react";

function fetchExchangeRateAsync(multiplier) {
    return new Promise(resolve => {
        setTimeout(() => {
            resolve(Math.random() * multiplier);
        }, 100);
    });
}

function CurrencyExchangeRate() {
    const [exchangeRate, setExchangeRate] = useState(null);
    const [inputValueString, setInputValueString] = useState("");

    const inputValue = Number(inputValueString);

    const [multiplier, setMultiplier] = useState(1.0);

    let outputValue = null;
    if (!isNaN(inputValue) && exchangeRate !== null) {
        outputValue = inputValue * exchangeRate;
    }

    useEffect(() => {
        // To avoid race conditions, we must not update the state from an asynchronous operation if
        // the component was re-rendered since then.
        //
        // https://overreacted.io/a-complete-guide-to-useeffect/
        let didCancel = false;

        // Do not delay the first request.
        fetchExchangeRateAsync(multiplier)
            .then(newExchangeRate => {
                if (!didCancel) {
                    setExchangeRate(newExchangeRate);
                }
            });
        
        // Poll exchange rate.
        let intervalHandle = setInterval(() => {
            fetchExchangeRateAsync(multiplier)
                .then(newExchangeRate => {
                    if (!didCancel) {
                        setExchangeRate(newExchangeRate);
                    }
                });
        }, 500);

        return () => {
            didCancel = true;
            clearInterval(intervalHandle);
        };
    }, [multiplier]);

    return (
        <div>
            <input value={inputValueString} onChange={event => setInputValueString(event.target.value)} /><br />
            <p>With current exchange rate: {outputValue !== null ? outputValue : "(loading)"}</p>
            <button onClick={() => setMultiplier(100.0)}>Set Multiplier</button>
        </div>
    );
}

function App() {
    return (
        <CurrencyExchangeRate />
    );
}

export default App;

This is quite a bit different from what you are doing but it does demonstrate that your code should generally work:

  • It is possible to trigger the fetch logic by changing the input field (here without debouncing) or in a given interval.

  • The fetch logic runs immediately when the component is rendered for the first time.

  • The "Set Multiplier" button can affect the value of multiplier and this information arrives in the setInterval call correctly.

    This works because [multiplier] dictates that the effect should be re-run if that variable changes. When this happens, the old interval is first cleared with clearInterval and then re-started with setInterval.

    In your case that would be pair, transactionType and fromCurrencyAmount instead of multiplier.

In other words, your issue seems to be outside the code that you provided in the question.

Related