1

Assuming you have a hook that has multiple useState, for example:

function useMyHook() {
  const [s1, setS1] = useState();
  const [s2, setS2] = useState();
  const setFn = useCallback(
    () => {
      console.log('set s1');
      setS1(Date.now());
      console.log('set s2');
      setS2(Date.now());
    },
    [setS1, setS2]
  );
  return setFn;
}

Now, depending of WHEN you call the setFn react will trigger either one or two rerender.

  • One rerender:
function MyComponent() {
  console.log('render')
  const setTwoStates = useMyHook()

  // sync call => triggers only one re render
  useEffect(() => setTwoStates(), [])
  // console.logs are: render, set s1, set s2, render

  return (
    <div>hello</div>
  );
}

  • Two rerenders:
function MyComponent() {
  console.log('render')
  const setTwoStates = useMyHook()

  // as soon as the setFn is called in a later tick, react triggers two re renders
  useEffect(() => setTimeout(setTwoStates, 1), [])
  // console.logs are: render, set s1, render, set s2, render 

  return (
    <div>hello</div>
  );
}

Does anyone have an explanation for this? This can lead to unexpected behaviour depending how you call a hook.

I also created a small example repository in case you want to play around with this

https://github.com/JohannesMerz/react-setstate-rerenders

Johannes Merz
  • 3,252
  • 17
  • 33

1 Answers1

1

React will batch state setters when they set state in useEffect callbacks or event handlers (while the function is running) but in your async example the state is set after the effect function has returned.

const syncFn = () => 
  console.log('in sync function');
const asyncFn = () => setTimeout(
  ()=>console.log('in async function'),
  10
);
syncFn();
console.log('sync function returned');
asyncFn();
console.log('async function returned');

You can see in that snippet that in async function logs after async function returned so any set states that would happen would happen after the effect callback or event handler returned and cannot be batched unless you explicitly tell React to batch it.

You can use unstable_batchedUpdates to tell react to batch the updates:

const App = () => {
  const [item1, setItem1] = React.useState(0);
  const [item2, setItem2] = React.useState(0);
  const render = React.useRef(0);
  //mutate render ref (shows how often component is rendered)
  render.current++;
  const asyncUpdates = React.useCallback(() => {
    Promise.resolve().then(() => {
      ReactDOM.unstable_batchedUpdates(() => {
        setItem1((item) => item + 1);
        setItem2((item) => item + 1);
      });
    });
  }, []);
  const syncUpdates = React.useCallback(() => {
    setItem1((item) => item + 1);
    setItem2((item) => item + 1);
  }, []);
  const asyncNonBatchedUpdates = React.useCallback(() => {
    Promise.resolve().then(() => {
      setItem1((item) => item + 1);
      setItem2((item) => item + 1);
    });
  }, []);
  //using asyncUpdates in effect on mount
  React.useEffect(asyncUpdates, []);
  return (
    <div>
      <h1>Rendered: {render.current}</h1>
      <div>
        <div>item1:{item1}</div>
        <div>item2:{item2}</div>
      </div>
      {/* run async updates as event handler */}
      <button onClick={asyncUpdates}>async updates</button>
      <button onClick={syncUpdates}>sync updates</button>
      <button onClick={asyncNonBatchedUpdates}>
        async non bathed updates
      </button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>

<div id="root"></div>
HMR
  • 37,593
  • 24
  • 91
  • 160
  • thx, that makes sense! A related article/answer i just found https://stackoverflow.com/questions/53048495/does-react-batch-state-update-functions-when-using-hooks – Johannes Merz Nov 13 '20 at 13:14