reduxjs / react-redux

Official React bindings for Redux
https://react-redux.js.org
MIT License
23.32k stars 3.37k forks source link

React & redux state mismatch, when using dispatch from useEffect #2188

Open nikitar opened 1 week ago

nikitar commented 1 week ago

What version of React, ReactDOM/React Native, Redux, and React Redux are you using?

What is the current behavior?

Made a simple 'counter' component that increments a counter when you click a button. It has more indirection than necessary, but it's a simplified version of a bug in some older code I'm looking at. Found it when trying to upgrade react-redux from 7 to 8. (Works fine in 7)

The basic issue is that setPendingIncrement(false) updates local state, then dispatch(counterActions.setValue(count + 1)) updates Redux state, however on the next render the local state update is not visible (yet) and Redux state change is already visible.

Now, I've read about stale props and zombie children, so I'm aware that the component will eventually re-render with both states in sync. That's fine, since the render function itself has no side-effects. However, I didn't expect the discrepancy to extend to useEffect. In useEffect, we do have side-effects, e.g. we could send a request to the server.

import {useEffect, useState} from 'react'
import './App.css'
import {useSelector, useDispatch} from 'react-redux';
import {counterActions} from './slices/counterSlice';

function App() {
    const count = useSelector((state) => state.counter.value);
    const dispatch = useDispatch();
    const [pendingIncrement, setPendingIncrement] = useState(false);

    console.log(`RENDER   ${count}   ${pendingIncrement}`);
    const onClick = () => {
        setPendingIncrement(true);
    }

    useEffect(() => {
        if (pendingIncrement) {
            setPendingIncrement(false);
            dispatch(counterActions.setValue(count + 1));
        }
    }, [pendingIncrement, setPendingIncrement, count, dispatch]);

    return (
        <button onClick={onClick}>
            count is {count}
        </button>
    )
}

export default App;

Complete example: https://snack.expo.dev/-JCou0gvPMHLWkAO5InqU (snack.expo.dev doesn't let you print to console though, so it's hard to debug there)

What is the expected behavior?

useEffect is called with React state and Redux state in sync. Or, if this pattern violates some principles of Redux, I'd love to understand the details. I.e. what exactly we can and cannot do.

This code works fine with react-redux 7, so it's at least a regression.

Which browser and OS are affected by this issue?

macos 14.5 / Chrome 126.0.6478.127

Did this work in previous versions of React Redux?

markerikson commented 1 week ago

This is probably the same issue as #1912 and #2086 - it's a limitation of useSyncExternalStore.

EskiMojo14 commented 1 week ago

in general, any next state that's calculated based on a previous state belongs in the reducers. that's the only way to guarantee that it updates correctly, similar to using a callback with useState.