Skip to content

Since React Native 0.69, useEffects appear to be flushed synchronously even in React legacy mode #35778

Closed
@mjmasn

Description

@mjmasn

Description

As I understand it, there was a change in React 18 to flush useEffects synchronously if they result from a render triggered by a user interaction (e.g. a button press). This was described in the React 18 changelog as:

Consistent useEffect timing: React now always synchronously flushes effect functions if the update was triggered during a discrete user input event such as a click or a keydown event. Previously, the behavior wasn't always predictable or consistent.

and I think relates to this PR: facebook/react#21150 which states:

For legacy mode, we will maintain the existing behavior, since it hasn't been reported as an issue, and we'd have to do additional work to distinguish "legacy default sync" from "discrete sync" to prevent all passive effects from being treated this way.

In React 17 the behaviour was asynchronous which allowed techniques such as calling setState then performing an expensive function inside a useEffect. This seems to be a fairly common technique in React, for example to show a spinner while fetching data (either on initial render or as the result of a filter etc. changing). For example:

export const MyComponent = () => {
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (loading) {
      for (let i = 0; i < 1000000000; i++) {
        // Expensive function (for example filtering a large list)
      }
      setLoading(false);
    }
  }, [loading]);

  const onPress = () => setLoading(true);
  
  return (
    <TouchableOpacity onPress={onPress}>
      <Text>
        {loading ? 'Loading...' : 'Press Me'}
      </Text>
    </TouchableOpacity>
  );
}

The above works fine in React 0.68 (press button, text changes to 'Loading...', the loop iterates, text changes back to 'Press Me') but in React Native 0.69 and above the button 'hangs' in the pressed-in state until the loop has completed, and the text never changes.

The reason I think this is a bug (and specifically a React Native bug) is because this new behaviour was not supposed to affect legacy rendering mode, yet in React Native it does. We can't use new React 18 features because we can't yet upgrade to the new architecture.

On the web using createRoot in React 18 exhibits this same 'button hanging' behaviour (as expected), but on both React 17, and React 18 in legacy render mode it works fine (text changes to loading before the useEffect runs).

The questions are:

  • Is this a legit bug?
  • Is this a known issue? (haven't been able to find much searching GH and the web)
  • Is there any (global) workaround to get the old behaviour (wrapping the code inside each useEffect in setTimeout(,0) works but we'd rather not have to do that everywhere)?

Version

0.69.0+

Output of npx react-native info

System:
OS: macOS 13.0.1
CPU: (8) arm64 Apple M1 Pro
Memory: 110.97 MB / 16.00 GB
Shell: 5.8.1 - /bin/zsh
Binaries:
Node: 16.10.0 - ~/.nvm/versions/node/v16.10.0/bin/node
Yarn: 1.22.11 - ~/.nvm/versions/node/v16.10.0/bin/yarn
npm: 8.0.0 - ~/.nvm/versions/node/v16.10.0/bin/npm
Watchman: 2022.10.03.00 - /opt/homebrew/bin/watchman
Managers:
CocoaPods: 1.11.3 - /Users/mike/.rbenv/shims/pod
SDKs:
iOS SDK:
Platforms: DriverKit 22.2, iOS 16.2, macOS 13.1, tvOS 16.1, watchOS 9.1
Android SDK: Not Found
IDEs:
Android Studio: 2021.3 AI-213.7172.25.2113.9014738
Xcode: 14.2/14C18 - /usr/bin/xcodebuild
Languages:
Java: 17.0.4.1 - /usr/bin/javac
npmPackages:
@react-native-community/cli: Not Found
react: 18.0.0 => 18.0.0
react-native: 0.69.5 => 0.69.5
react-native-macos: Not Found
npmGlobalPackages:
react-native: Not Found

Steps to reproduce

See example repos below. The react native ones (Effect68/69/70) can be run as a normal RN app (npm start, npx react-native run-android etc). The react web ones (Effect170/180/181/182) use esbuild and can be run with npm run build which will run esbuild in serve mode at http://127.0.0.1:8000/ by default.

Run the app then click the button. The expected behaviour (at least in React 17 / React 18 legacy mode) is that the button text changes to 'Loading...' when pressed and reverts to 'Press Me' once the loop has run.

In the react web examples, I've left the legacy mode rendering code and imports commented out to allow quick switching between old and new.

Effect68 and Effect170 show the old behaviour
Effect69, Effect70, Effect180, Effect181, Effect182 show the new behaviour
Effect180, Effect181, Effect182 can be switched to show the old behaviour by using the legacy render function rather than createRoot

Snack, code example, screenshot, or link to a repository

https://github.com/mjmasn/Effect68 (React Native 0.68 (React 17))
https://github.com/mjmasn/Effect69 (React Native 0.69 (React 18))
https://github.com/mjmasn/Effect70 (React Native 0.70 (React 18))
https://github.com/mjmasn/Effect170 (React Web 17.0.2)
https://github.com/mjmasn/Effect180 (React Web 18.0.0)
https://github.com/mjmasn/Effect182 (React Web 18.2.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Impact: RegressionDescribes a behavior that used to work on a prior release, but stopped working recently.Needs: Triage 🔍Resolution: PR SubmittedA pull request with a fix has been provided.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions