Search code examples
reactjsreact-hooksuse-effectuse-state

Avoiding stale state in double useEffect?


I have two useEffect hooks in a component (InternalComponent) that displays a single data item. One useEffect tracks a count as state, POSTing to a database when the user increments the count. The second useEffect resets the count when the item tracked by the InternalComponent changes (due to external manipulation).

The problem is: the useEffect that updates the database will fire when the item changes; it will see the new item value, but will incorrectly send the database the count from the previous item. This occurs because the two useEffects fire "simultaneously", with the state that would indicate the count shouldn't be POSTed not being set until after the POST useEffect is run.

const InternalComponent = ({ item }) => {
  const [count, setCount] = useState(item.count);
  const [countChanged, setCountChanged] = useState(false);

  useEffect(() => {
    console.log(
      `Item is now ${item.id}; setting count from item and marking unchanged.`
    );
    setCount(item.count);
    setCountChanged(false);
  }, [item]);

  useEffect(() => {
    if (countChanged) {
      console.log(
        `Count changed for item ${item.id}, POSTing (id=${item.id}, count=${count}) to database.`
      );
    } else {
      console.log(
        `Count hasn't changed yet, so don't update the DB (id=${item.id}, count=${count})`
      );
    }
  }, [item, count, countChanged]);

  const handleButtonClick = () => {
    setCount(count + 1);
    setCountChanged(true);
  };

  return (
    <div>
      I'm showing item {item.id}, which has count {count}.<br />
      <button onClick={handleButtonClick}>Increment item count</button>
    </div>
  );
};

Minimal working example on Code Sandbox: https://codesandbox.io/s/post-with-stale-data-vfzh4j?file=/src/App.js

The annotated output:

1 (After button click) Count changed for item 1, POSTing (id=1, count=6) to database. 
2 (After item changed) Item is now 2; setting count from item and marking unchanged. 
3 Count changed for item 2, POSTing (id=2, count=6) to database. 
4 (useEffect runs twice) Count hasn't changed yet, so don't update the DB (id=2, count=50) 

Line 3 is the unwanted behavior: the database will receive a POST with the wrong item ID and that ideally shouldn't have been sent at all.

This feels like a simple/common problem: what design am I supposed to use to prevent the POST useEffect from firing with stale state? All the solutions I can easily think of seem absurd to me. (e.g. creating one InternalComponent for each item and only displaying one of them, combining all the useEffects into a single giant useEffect that tracks every state in the component, etc.) I'm sure I'm overlooking something obvious: any ideas? Thank you!


Solution

  • The issue is caused by using item as a dependency for both useEffect hooks. When the item prop updates the second useEffect hook is triggered with the old count state, then again a second time after the first useEffect hook updates the count state.

    You basically just want to "reset" the InternalComponent state when the item prop updates. Just use the item's id as a React key on the InternalComponent.

    Example:

    <InternalComponent key={item.id} item={item} />
    

    Then the key changes React throws away (unmounts) the previous instance and mounts a new instance with new state initialized as you expect.

    Because InternalComponent is remounted/reset, the second useEffect hook is completely extraneous now and can be removed. The state values will pick up the new item prop value.

    const InternalComponent = ({ item }) => {
      const [count, setCount] = useState(item.count);
      const [countChanged, setCountChanged] = useState(false);
    
      useEffect(() => {
        if (countChanged) {
          console.log(
            `Count changed for item ${item.id}, POSTing (id=${item.id}, count=${count}) to database.`
          );
        } else {
          console.log(
            `Count hasn't changed yet, so don't update the DB (id=${item.id}, count=${count})`
          );
        }
      }, [item, count, countChanged]);
    
      const handleButtonClick = () => {
        setCount(count + 1);
        setCountChanged(true);
      };
    
      return (
        <div>
          I'm showing item {item.id}, which has count {count}.<br />
          <button onClick={handleButtonClick}>Increment item count</button>
        </div>
      );
    };
    

    Edit avoiding-stale-state-in-double-useeffect