-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Description
What version of React, ReactDOM/React Native, Redux, and React Redux are you using?
- React: 18.2.0
- ReactDOM/React Native: ^18.2.0
- Redux: 5.0.1
- React Redux: 9.2.0
What is the current behavior?
I am trying to migrate the version of redux from 4.0.4 -> 5.0.1 and react-redux from 7.1.1 -> 9.2.0.
Summary of the Issue:
A specific, and not uncommon, pattern of hook usage that was safe in react-redux v7 now causes an infinite re-render loop in v9. The loop occurs when a useEffect depends on a memoized function (useCallback) which, in turn, depends on the result of a useSelector that always returns a new object/array reference.
The architectural shift in react-redux v8+ to use React 18's useSyncExternalStore hook appears to be the root cause of this new, stricter behavior. The new, more immediate update mechanism creates a feedback loop that the older, batch-oriented subscription model did not.
import { useEffect, useCallback } from 'react';
import { useSelector, shallowEqual } from 'react-redux';
// An "unstable" selector that always returns a new object reference,
// even if the underlying data is identical. This is the origin of the problem.
const selectUnstableData = (state) => ({ ...state.some.data });
function ProblematicComponent() {
// A selector that triggers the initial run of the useEffect (e.g., an API call status).
const isReadyToSubmit = useSelector(state => state.isReadyToSubmit);
// A selector that will change as a result of the useEffect's action,
// causing a re-render that restarts the loop.
const updateTrigger = useSelector(state => state.updateTrigger);
// This selector uses the unstable function. The `shallowEqual` function correctly
// prevents THIS HOOK from causing a re-render on its own. However, on any
// render triggered by something else (like `updateTrigger`), this selector
// will still run and return a new object reference into the component's scope.
const unstableData = useSelector(selectUnstableData, shallowEqual);
// This callback function is memoized, but its stability is compromised.
// It is re-created every time `unstableData` gets a new object reference,
// which happens on every single render of this component.
const memoizedSubmitCallback = useCallback(() => {
// In a real app, this dispatches an action that will change `updateTrigger`.
console.log('This callback is unstable and will be recreated on every render.');
}, [unstableData]); // <-- Dependency on the unstable object reference.
// This useEffect creates the infinite loop in v9.x.
useEffect(() => {
// 1. This block runs the first time `isReadyToSubmit` becomes true.
if (isReadyToSubmit) {
console.log('Effect is running, calling the submit callback...');
memoizedSubmitCallback();
}
// 2. The dependency on `memoizedSubmitCallback` is the key to the loop.
}, [isReadyToSubmit, memoizedSubmitCallback]);
return (
// ... JSX
);
}
Detailed Step-by-Step Explanation of the Infinite Loop:
The infinite loop is created by a precise, predictable chain reaction of hook invalidations.
Initial Trigger: An external event occurs. For example, a user clicks a button, an API call returns, and the state isReadyToSubmit becomes true. This causes ProblematicComponent to re-render.
First useEffect Run: After this first render, the useEffect hook runs because its dependency isReadyToSubmit has changed. It calls memoizedSubmitCallback().
State Update and Re-render: The memoizedSubmitCallback() dispatches an action to the Redux store. This action changes the updateTrigger state value. The change to updateTrigger causes ProblematicComponent to schedule a new re-render.
The Instability Cascade (The Core of the Bug): This is where the loop begins. During the new re-render caused by updateTrigger:
The useSelector for unstableData runs again. As designed, our selectUnstableData function creates and returns a brand new object reference.
The useCallback for memoizedSubmitCallback now checks its dependencies. It compares the new object reference from this render with the one from the previous render. Since the references are different (oldData !== newData), useCallback is forced to discard the previously memoized function and create a brand new function reference for memoizedSubmitCallback.
The Infinite useEffect Trigger: After this re-render completes, React checks the dependencies of the useEffect. It compares the memoizedSubmitCallback reference from this render with the one from the previous render. They are different!
Because one of its dependencies has changed, the useEffect is required to run again.
It calls memoizedSubmitCallback() again.
This dispatches the same action, which changes updateTrigger again (or causes a related state change), leading directly back to Step 4.
This cycle—Render -> New Data Ref -> New Function Ref -> useEffect Runs -> New Render—repeats infinitely.
This issue was not happening in my previous version 7.1.1. This is occuring when I am using the version 9.2.0 in my project.
This is blocking my migration and I am not even sure, in how many areas of my codebase this might break which might cause numerous outages in my product.
What is the expected behavior?
It would be great if someone can help me on what exactly is the change in the behavior of useSelector between these versions of react-redux, so that I can find all the issues that might arise all throughout my project and fix them.
Which browser and OS are affected by this issue?
No response
Did this work in previous versions of React Redux?
- Yes