reactjsreact-hookseslintlint

When a useCallback function is called both by a useEffect and an onClick handler is there a way to have exaustive deps to the useEffect?


I have a pseudo-code React component similar to this one:

const codeApplier = ({ code, prices }: Props) => {
  const [isAccepted, setIsAccepted] = useState(false);

  const applyCode = useCallback(async () => {
    const validationResult = await callToApplyCode(code, prices);

    if (validationResult.status === "OK") {
      setIsAccepted(true);
    } else {
      setIsAccepted(false);
    }
    //do more stuff

  }, [code, prices]);

  useEffect(() => {
    if (isAccepted) applyCode();
  }, [prices]);

  return <button onClick={applyCode} />;
};

The important parts:

  • If the user clicks the button I want to apply the code
  • If the prices change I want to reapply the code

Now, the component like this works fine, but the linter complains that on the useEffect I am not listing all the dependencies. The infamous exhaustive-deps rule.

The issue is: if I add "applyCode" (or even isAccepted) to the dependencies of the useEffect like this:

  useEffect(() => {
    if (isAccepted) applyCode();
  }, [isAccepted, applyCode, prices]);

then the linter is pleased, but useEffect is going to duplicate the call at each click.

  1. change "code" -> automatically change "applycode" -> Expected
  2. onclick -> call the new applycode -> Expected
  3. callToApplyCode -> Expected
  4. useEffect triggered by the "applycode" change -> applycode(); -> Unexpected
  5. another callToApplyCode -> Not wanted

It this one of those cases where disabling exhaustive-deps is ok? Or is there a way to refactor the component without angrying the linter?

I looked at this comment: https://github.com/facebook/react/issues/14920#issuecomment-471070149 and various similar questions on StackOverflow, but I was not able to find an answer to my specific case.


Solution

  • If you want only the change of prices to trigger the useEffect, save the previous state of prices in a ref, and whenever the useEffect is triggered by deps change, check if current & previous prices are the same. If they are, bail out.

    const codeApplier = ({ code, prices }: Props) => {
      const prevPricesRef = useRef(prices); // ref for previous state of prices
      const [isAccepted, setIsAccepted] = useState(false);
    
      const applyCode = useCallback(async() => {
        const validationResult = await callToApplyCode(code, prices);
    
        setIsAccepted(validationResult.status === "OK");
    
        //do more stuff
    
      }, [code, prices]);
    
      useEffect(() => {
        if(prices === prevPricesRef.current) return; // bail out if current & prev prices are identical
        
        prevPricesRef.current = prices; // update prev prices
      
        if (isAccepted) applyCode();
      }, [prices, isAccepted, applyCode]);
    
      return <button onClick={applyCode} />;
    };