Here is a basic example of the issue. A component contains two nested div
s. We can keep track of the "depth" of the mouse position (0
if outside both div
s, 1
if inside the outer div
but outside the inner div
, 2
if inside the inner div
) by using useState
to store the current depth and creating two useCallback
-ed event handlers, one which increments the depth value and one which decrements it (then attaching these event handlers appropriately).
This mostly works fine, but if we move the mouse as fast as we can from inside the inner div to the outside, sometimes React fails to rerender the component between the firing of the events, so both copies of the decrement event handler run within the same render, and we have a race condition -- the component is left thinking the depth value is 1
. The snippet below showcases exactly this (move your mouse slowly into the blue then very fast out back to the white).
What's the best way to modify the situation to prevent this? My current best idea is to add a queue (of increments/decrements) to the component and some kind of useEffect(..., [ queue ])
to guarantee processing the queue one operation at a time. It would be nice if anyone can offer something simpler/more ergonomic (in the real application, operations are obviously more complex than just increments/decrements, so it would be nice to avoid the complexity of queueing these unless totally necessary). Ideally I would just like to be able to tell the component to take over control of (mouse) event dispatch on its children and buffer these appropriately.
I can't find any fixes through Googling. The best match I can find is here but this is from 2019 so very possibly outdated and the main link is dead.
function App() {
// console.log("---RENDER---");
const [value, setValue] = React.useState(0);
const onMouseEnter = React.useCallback(() => {
// console.log(" incr");
setValue(value+1);
}, [ value ]);
const onMouseLeave = React.useCallback(() => {
// console.log(" decr");
setValue(value-1);
}, [ value ]);
const events = { onMouseEnter, onMouseLeave };
return (
<div>
<h1>{value}</h1>
<div style={{"background": "blue", "padding": "1rem"}} {...events}>
<div style={{"background": "red", "width": "2rem", "height": "2rem"}} {...events}>
</div>
</div>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<div id="root" />
EDIT: As @SergeySosunov pointed out, the bug in the snippet can be fixed by using the functional update form of setState
. But imagine that the event handler for onMouseLeave
then needs to perform some (complicated, expensive) piece of logic which depends on additional parameters passed from the event source, and also depends on the value of state
immediately after the update. Because of the dependence on additional parameters of the specific event, we can't just pass down responsibility for this additional piece of logic to some kind of useEffect(..., [ state ])
hook, because then we lose information about what caused the change to state
. We also cannot perform the logic immediately, because as evidenced by the snippet, we don't necessarily know the true value of state
.
setValue(curr => curr - 1)
?state
value, which is needed here. I understand the reason for the behaviour. I am just wondering if anyone has run into the issue before (it is a real limitation, not addressed solely by functional updates) and knows of a solution. E.g. it seems feasible to me that with sufficient magic done on DOM rendering, React could "take control" of event dispatch within components to ensure rerenders happen between every pair of events.useReducer
create your state, create your actions to update state, you should be able to make this do what you are asking for (if I understand correctly). Or you can step outside react and create an object/class that can handle this and send updates via subscribe / unsubscribe functionality.