React 18 - Bug: useEffect called twice in Strict Mode
Since React 18, useEffect is called twice in Strict Mode when zero dependencies.
This is following on from this tweet:
https://twitter.com/dan_abramov/status/1523652274748559360
The purpose of logging this issue is not to report the problem. The purpose of this issue is to present a scenario where the new behaviour is awkward / difficult to work with, migrating components is not easy, as such I advocate calling this out as a breaking change, and hopefully something that can be turned off in future React versions.
Link to code example:
https://stackblitz.com/edit/react-useeffect-called-twice-scenario
React version: 18
Steps To Reproduce:
Note the UI rendering with / without strict mode is different.
** Problem Pattern 1
UI components from libraries need to maintain their own state & services. They cannot participate with state & services of the hosting application, as then encapsulation of the component is lost. A reliable way is needed to create the state & services of such a component bound to the lifecycle of the component. The new pattern (useEffect() called twice) means the state & services will unnecessarily get created and destroyed twice, along with the useEffect. For a datagrid, this could mean 100,000 rows passed to the grid getting sorted and grouped twice, when it should be once. This has two bad outcomes 1) consumers of the library will observe this and will complain, without knowing it's an intended pattern of React 2) it is bad practice to have different code paths executed in Dev vs Prod modes.
** Problem Pattern 2
For groups of components, where there is shared logic that executes when all components are ready, there is no reliable way to know when all required components are ready, as the components can get destroyed and re-initialised.
In the provided example, note that each React Component works with a Controller class. All the logic is delegated to the Controller class, making the React Component only responsible for DOM operations. This allows the UI to be swapped out with a different rendering engine, while keeping all the logic. This is what AG Grid uses to allow it to use React to render when used with React, and SomethingElse when used with SomethingElse. There are parts of the logic that wait for all Controllers (and associated Components) to be ready before doing certain steps. In React, because the Controllers lifecycle is tied to the useEffect, it means the Controllers are not tied to the Components lifecycle, and resulting in stale references for old Controllers.
In the example code, see ControllersService. This service is what the components register to, and when all components are registered, grid code that was waiting for the components to be ready is notified.
** Breaking Change
This change forces the other non-UI parts of the application to have a fundamental change in how they work. This means React is breaking from the following statement (taken from reactjs.org): "Learn Once, Write Anywhere - We don’t make assumptions about the rest of your technology stack, so you can develop new features in React without rewriting existing code."
**
Yes we could re-write how we do things in AG Grid, however then the bug I am raising is this needs to be documented as a breaking change and React 18 is not backwards compatible.
this needs to be documented as a breaking change and React 18 is not backwards compatible.
Just to start with this — React 18 is a major release and is not meant to be fully backwards-compatible. Each major release has breaking changes; otherwise we wouldn't mark them as major. The change is documented in the upgrade blog post. It is also marked as a breaking change in the changelog.
I'll respond to the rest later.
Just to start with this — React 18 is a major release and is not meant to be fully backwards-compatible. Each major release has breaking changes; otherwise we wouldn't mark them as major. The change is documented in the upgrade blog post. It is also marked as a breaking change in the changelog.
Noted, fair points.
My workaround:
import { DependencyList, EffectCallback, useEffect } from 'react'
export default process.env.NODE_ENV === 'production'
? useEffect
: (effect: EffectCallback, deps?: DependencyList) => {
useEffect(() => {
let ready = true
let destructor: void | (() => void)
queueMicrotask(() => {
if (ready) destructor = effect()
})
return () => {
ready = false
destructor?.()
}
}, deps)
}