When accessing a state variable from a useCallback, value is not updated

Viewed 1488

At a certain place of my code I'm accessing a state variable of my component from a call back ( UserCallback ) and I find the state variable has not updated from the initial value and call back is referring to the initial value. As I read in the documentation when variable is passed as one of array items then it should update the function when it is updated. Following is a sample code.


const Child = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);
  const node = useRef(null);

  useImperativeHandle(ref, () => ({
    increment() {
      setCount(count + 1);
    }
  }));

  const clickListener = useCallback(
    e => {
      if (!node.current.contains(e.target)) {
        alert(count);
      }
    },
    [count]
  );

  useEffect(() => {
    // Attach the listeners on component mount.
    document.addEventListener("click", clickListener);
    // Detach the listeners on component unmount.
    return () => {
      document.removeEventListener("click", clickListener);
    };
  }, []);

  return (
    <div
      ref={node}
      style={{ width: "500px", height: "100px", backgroundColor: "yellow" }}
    >
      <h1>Hi {count}</h1>
    </div>
  );
});

const Parent = () => {
  const childRef = useRef();

  return (
    <div>
      <Child ref={childRef} />
      <button onClick={() => childRef.current.increment()}>Click</button>
    </div>
  );
};

export default function App() {
  return (
    <div className="App">
      <Parent />
    </div>
  );
}


What I'm originally building is a custom confirmation modal. I have a state variable which set either display:block or display:none to the root element. Then if there is a click outside the component I need to close the modal by setting state variable to false. Following is the original function.

  const clickListener = useCallback(
    (e: MouseEvent) => {
      console.log('isVisible - ', isVisible, ' count - ', count, ' !node.current.contains(e.target) - ', !node.current.contains(e.target))
      if (isVisible && !node.current.contains(e.target)) {
        setIsVisible(false)
      }
    },
    [node.current, isVisible],
  )

It doesn't get closed because isVisible is always false which is the initial value.

What am I doing wrong here?

For further clarifications following is the full component.

const ConfirmActionModal = (props, ref) => {

  const [isVisible, setIsVisible] = useState(false)
  const [count, setCount] = useState(0)

  const showModal = () => {
    setIsVisible(true)
    setCount(1)
  }

  useImperativeHandle(ref, () => {
    return {
      showModal: showModal
    }
  });

  const node = useRef(null)
  const stateRef = useRef(isVisible);

  const escapeListener = useCallback((e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      setIsVisible(false)
    }
  }, [])

  useEffect(() => {
    stateRef.current = isVisible;
  }, [isVisible]);

  useEffect(() => {
    const clickListener = e => {
      if (stateRef.current && !node.current.contains(e.target)) {
        setIsVisible(false)
      }
    };

    // Attach the listeners on component mount.
    document.addEventListener('click', clickListener)
    document.addEventListener('keyup', escapeListener)
    // Detach the listeners on component unmount.
    return () => {
      document.removeEventListener('click', clickListener)
      document.removeEventListener('keyup', escapeListener)
    }
  }, [])

  return (
    <div ref={node}>
      <ConfirmPanel style={{ display : isVisible ? 'block': 'none'}}>
        <ConfirmMessage>
          Complete - {isVisible.toString()} - {count}
        </ConfirmMessage>
        <PrimaryButton
          type="submit"
          style={{
            backgroundColor: "#00aa10",
            color: "white",
            marginRight: "10px",
            margin: "auto"
          }}
          onClick={() => {console.log(isVisible); setCount(2)}}
        >Confirm</PrimaryButton>
      </ConfirmPanel>
    </div>

  )

}

export default forwardRef(ConfirmActionModal)
3 Answers

You assign a function clickListener to document.addEventListener on component mount, this function has a closure on count value.

On the next render, the count value will be stale.

One way to solve it is implementing a function with refernce closure instead:

const Child = forwardRef((props, ref) => {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    // countRef.current always holds the most updated state
    const clickListener = e => {
      if (!node.current.contains(e.target)) {
        alert(countRef.current);
      }
    };

    document.addEventListener("click", clickListener);
    return () => {
      document.removeEventListener("click", clickListener);
    };
  }, []);
...
}

Edit fast-wood-stsrn

You can pass a callback to setIsvisible so you don't need isVisible as a dependency of the useCallback. Adding node.current is pointless since node is a ref and gets mutated:

const clickListener = useCallback((e) => {
  setIsVisible((isVisible) => {//pass callback to state setter
    if (isVisible && !node.current.contains(e.target)) {
      return false;
    }
    return isVisible;
  });
}, []);//no dependencies needed

While your clickListener does change when count changes you only bind the initial clickListener once on mount because your useEffect dependency list is empty. You could ad clickListener to the dependency list as well:

useEffect(() => {
    // Attach the listeners on component mount.
    document.addEventListener("click", clickListener);
    // Detach the listeners on component unmount.
    return () => {
      document.removeEventListener("click", clickListener);
    };
}, [clickListener]);

Side note: using node.current in a dependency list doesn't do anything as react does not notice any changes to a ref. Dependencies can only be state or props.

Related