react: Bug: in strict mode, states are rerendered out of order

Using a combination of useRef and useState I am able to create a scenario where a state value is updated, then a subsequent rerender caused by strict mode uses the old value of the state.

React version: 17 and 18

Steps To Reproduce

Using the following script, click “show”, then “hide”, then “show” again. The second time you click “show”, the value ready changes from true to false (expected) and then immediately to true again (unexpected).

Code example:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Hello World</title>
    <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>

    <!-- Don't use this in production: -->
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/babel">

const useHasChanged = (state) => {
  const previousStateRef = React.useRef(state);
  if (
    previousStateRef.current.every((value, index) => value === state[index])
  ) {
    return false;
  }
  previousStateRef.current = state;
  return true;
};

const Thing = ({ visible, setVisible }) => {
  const [ready, setReady] = React.useState(false);
  if (useHasChanged([visible]) && visible) {
    console.info("setReady(false)");
    setReady(false);
    setTimeout(() => {
        console.info("setReady(true)");
        setReady(true)
    }, 1000)
  }
  console.info("Thing", { ready, visible });
  return <div>{ visible && (<><p>visible: {`${visible}`}, ready: {`${ready}`}</p><p><a
  onClick={() => {
      console.info("hide");
      setVisible(false)
  }}>hide</a></p></>) }</div>
}

const App = () => {
  const [visible, setVisible] = React.useState(false);
  return <React.StrictMode>
  <div><Thing visible={visible} setVisible={setVisible} /><p><a
  onClick={() => {
  console.info("show");
    setVisible(true)
    }}>show</a></p></div></React.StrictMode>
}

const container = document.getElementById('root')
const root = ReactDOM.createRoot(container);
root.render(<App />)

    </script>
  </body>
</html>

The current behavior

Thing {ready: false, visible: false}
Thing {ready: false, visible: false}
show
Thing {ready: false, visible: true}
setReady(false)
Thing {ready: false, visible: true}
Thing {ready: false, visible: true}
setReady(true)
Thing {ready: true, visible: true}
Thing {ready: true, visible: true}
hide
Thing {ready: true, visible: false}
Thing {ready: true, visible: false}
show
Thing {ready: true, visible: true}
setReady(false)
Thing {ready: false, visible: true}
Thing {ready: true, visible: true}     <-- HERE is the stale state being replayed
setReady(true)
Thing {ready: true, visible: true}
Thing {ready: true, visible: true}

The expected behavior

Thing {ready: false, visible: false}
Thing {ready: false, visible: false}
show
Thing {ready: false, visible: true}
setReady(false)
Thing {ready: false, visible: true}
Thing {ready: false, visible: true}
setReady(true)
Thing {ready: true, visible: true}
Thing {ready: true, visible: true}
hide
Thing {ready: true, visible: false}
Thing {ready: true, visible: false}
show
Thing {ready: true, visible: true}
setReady(false)
Thing {ready: false, visible: true}
Thing {ready: false, visible: true}
setReady(true)
Thing {ready: true, visible: true}
Thing {ready: true, visible: true}

Notes

Interestingly, if I change the useHasChanged implementation to useState instead of useRef, the problem isn’t visible:

export const useHasChanged = (state) => {
  const [previousState, setPreviousState] = useState(state);
  if (previousState.every((value, index) => value === state[index])) {
    return false;
  }
  setPreviousState(state);
  return true;
};

It looks like the reason is that when the ready true state is replayed, the previousState [false] state is also replayed, causing it to setReady(false) a second time, which “corrects” the stale replayed state.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 2
  • Comments: 17 (1 by maintainers)

Most upvoted comments

I’ve also encountered this bug: when running in “strict” mode in development, the component re-renders with an out-of-place stale state.

Observed:

  • The initial state value is “A”
  • Functional component render function is run
  • useLayoutEffect() is called
    • Inside that effect, the state variable is set to “B”
  • useLayoutEffect() is immediately unmounted (by design of “strict” mode)
  • useLayoutEffect() is called again
    • Inside that effect, the state variable is set to “C”
  • The component re-renders with state value being equal to “B”
  • The program crashes because it was expecting “C”

Expected:

  • The component should’ve re-rendered with the latest state value — “C”

Possible hacky workarounds:

  • I could modify the state update function to write the latest argument value to a “ref” and then compare it to that “ref” on a subsequent re-render.
import { useRef, useState, useCallback } from 'react'

// This hook fixes any weird intermediate inconsistent/invalid/stale state values.
// https://github.com/facebook/react/issues/25023#issuecomment-1480463544
export default function useStateNoStaleBug(initialState) {
  // const latestValidState = useRef(initialState)
  const latestWrittenState = useRef(initialState)
  const [_state, _setState] = useState(initialState)
  
  // Instead of dealing with a potentially out-of-sync (stale) state value, 
  // simply use the correct latest one.
  const state = latestWrittenState.current

  /*
  let state
  if (_state === latestWrittenState.current) {
    state = _state
    latestValidState.current = _state
  } else {
    // React bug detected: an out-of-sync (stale) state value received.
    // Ignore the out-of-sync (stale) state value.
    state = latestValidState.current
  }
  */
    
  const setState = useCallback((newState) => {
    if (typeof newState === 'function') {
      throw new Error('Function argument of `setState()` function is not supported by this hook')
    }
    latestWrittenState.current = newState
    _setState(newState)
  }, [])
  
  return [state, setState]
}

Yep, we ran into few issues recently using v. 18.2.0 (and Next.js 12.3.3) when extra re-render appeared with wrong (outdated) state out of nowhere. It reproduces only in development mode, but in production mode everything works as expected.

In the latest issue I’ve explored, it was just extra render caused by flush something in fibers. The component caused an issue has an updating re-render when props change and an effect (not related directly, but triggered by the state update).

I think what’s surprising about this example is that the docs seem to say that strict mode’s rerendering is the same as unmounting and remounting the component. But I don’t believe that unmounting and remounting the component would cause it to render with a stale state; I would expect the second time it renders to use the state set by the first render.