Description
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
insetTimeout(,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)