4

I have an example like this:

codesandebox

I want to modify a state value in a callback, then use the new state value to modify another state.

export default function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("0");
  const [added, setAdded] = useState(false);

  const aNotWorkingHandler = useCallback(
    e => {
      console.log("clicked");
      setCount(a => ++a);
      setText(count.toString());
    },
    [count, setCount, setText]
  );

  const btnRef = useRef(null);
  useEffect(() => {
    if (!added && btnRef.current) {
      btnRef.current.addEventListener("click", aNotWorkingHandler);
      setAdded(true);
    }
  }, [added, aNotWorkingHandler]);

return <button ref={btnRef}> + 1 </button>

However, after this handler got called, count has been successfully increased, but text hasn't.

Can you guys help me to understand why this happened? and how to avoid it cleanly?

Thank you!

Yang Yang
  • 41
  • 1
  • 1
  • 2
  • 1
    Are count and text ever supposed to be unrelated to eachother? Like, do you have a case where you want count to be `5` but text to be `"392"`? If they're always supposed to be in lockstep, then don't have two states, just one. – Nicholas Tower Apr 14 '20 at 18:38

2 Answers2

4

If count and state are always supposed to be in lockstep, just with one being a number and one being a string, then i think it's a mistake to have two state variables. Instead, just have one, and derive the other value from it:

const [count, setCount] = useState(0);
const text = "" + count;
const [added, setAdded] = useState(false);

const aNotWorkingHandler = useCallback(
  e => {
    setCount(a => ++a);
  },
  []
);

In the above useCallback, i have an empty dependency array. This is because the only thing that's being used in the callback is setCount. React guarantees that state setters have stable references, so it's impossible for setCount to change, and thus no need to list it as a dependency.

Nicholas Tower
  • 72,740
  • 7
  • 86
  • 98
  • Thank you for you answer. However, the above example is simplified to describe my question. We will encounter some real-life problems similar to it. For instance, an ID generator. If I don't have a counter to keep tracking my ID, I would have to iterate through a large array or object to find what's the next ID(especially when the array is modified and length is not reliable). – Yang Yang Apr 15 '20 at 21:32
  • 4
    **React guarantees that state setters have stable references, so it's impossible for setCount to change.** I just needed this sentence in bold text as it was exactly what I've been looking for. Thank you! – kub1x May 13 '21 at 17:54
1

There are few things causing the issue.

Setter does not update the count value immediately. Instead it "schedules" the component to re-render with the new count value returned from the useState hook. When the setText setter is called, the count is not updated yet, because the component didn't have chance to re-render in the mean time. It will happen some time after the handler is finished.

setCount(a => ++a);        // <-- this updates the count after re-render
setText(count.toString()); // <-- count is not incremented here yet

You are calling addEventListener only once and it remembers the first value of count. It is good you have aNotWorkingHandler in the dependencies - the onEffect is being re-run when new count and thus new handler function comes. But your added flag prevents the addEventListener from being called then. The button stores only the first version of the handler function. The one with count === 0 closured in it.

useEffect(() => {
  if (!added && btnRef.current) { // <-- this prevents the event handler from being updated
    btnRef.current.addEventListener("click", aNotWorkingHandler); // <-- this is called only once with the first instance of aNotWorkingHandler
    setAdded(true);
  } else {
    console.log("New event handler arrived, but we ignored it.");
  }
}, [added, aNotWorkingHandler]); // <-- this correctly causes the effect to re-run when the callback changes

Just removing the added flag would, of course, cause all the handlers to pile up. Instead, just use onClick which correctly adds and removes the event handler for you.

<button onClick={aNotWorkingHandler} />

In order to update a value based on another value, I'd probably use something like this (but it smells of infinite loop to me):

useEffect(
  () => {
    setText(count.toString());
  },
  [count]
);

Or compute the value first, then update the states:

  const aNotWorkingHandler = useCallback(
    (e) => {
      const newCount = count + 1;
      setCount(newCount);
      setText(newCount.toString());
    },
    [count]
  );

I agree with @nicholas-tower, that if the other value does not need to be explicitly set and is always computed from the first one, it should be just computed as the component re-renders. I think his answer is correct, hope this context answers it for other people getting here.

kub1x
  • 3,272
  • 37
  • 38