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)
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:
useLayoutEffect()is calleduseLayoutEffect()is immediately unmounted (by design of “strict” mode)useLayoutEffect()is called againExpected:
Possible hacky workarounds:
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.