StrictMode effect double-invocation limits for external resource management
0 reputation · 09 Dec 2024, 10:05 UTC
0 reputation · 09 Dec 2024, 10:05 UTC
React's <StrictMode> intentionally double-invokes useEffect setup and cleanup functions in development builds. This behavior is designed to ensure that components are resilient to remounting and that cleanup logic correctly releases resources such as WebSocket connections, timers, or event subscriptions.
When effects interact with non-React systems—such as incrementing a server-side counter or initializing a third-party library—the double-invocation can lead to duplicate side effects if the logic is not idempotent or lacks a corresponding cleanup function. While production builds execute these effects once per mount, the development behavior surfaces leaks that may otherwise only appear during navigation or when using Suspense boundaries.
There is often uncertainty regarding the strategy for handling legacy subtrees where strict cleanup is not currently implemented but removing <StrictMode> would mask potential production bugs.
<StrictMode> to specific component branches?29275 reputation · 09 Dec 2024, 16:22 UTC
No—a failure under StrictMode's double-invocation does not automatically mean a production bug today, but it almost always means the effect is fragile. And the decision between refactoring for idempotency versus scoping <StrictMode> comes down to one question: does the effect's resource have a correct cleanup path, or is the cleanup missing entirely?
In React 18 and later, development builds with <StrictMode> mount each component, then immediately unmount and remount it. Every effect runs setup → cleanup → setup. Production builds run effects once per real mount. So a counter that increments twice in dev increments once in production—today.
The catch: StrictMode is simulating a remount. Any real remount in production—navigating away and back, a Suspense boundary re-revealing content, a key change, or future React features that preserve state across unmounts—exercises the same path. If your effect leaks on the simulated remount, it leaks on the real one.
A dev-only double-invocation failure indicates a genuine bug when the effect acquires something without releasing it: a WebSocket opened but never closed, an interval never cleared, a global third-party object mutated without a teardown. These leak on any real remount, not just StrictMode's.
It is usually not a production bug when the effect is idempotent by nature (re-registering the same listener with the same arguments) or when the duplicated action is dev-only noise, like a logging call. The test is simple: unmount and remount the component in a production build and check whether the external resource is correctly released and reacquired.
Return a cleanup that fully reverses the setup. For async work, guard against the discarded first run:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(() => {}); // ignore abort from the discarded run
return () => controller.abort();
}, [url]);For resources that must survive remounts (a shared socket), move ownership out of the component: a module-level manager with reference counting, so the first cleanup decrements but does not destroy what the second setup still needs. Avoid the didMount ref flag—it suppresses the symptom while leaving the effect broken for real remounts.
Add a console.log in setup and cleanup; in dev you should see exactly one setup → cleanup → setup per mount. Then build for production (npm run build + serve) and confirm one setup per mount. Finally, write a test that mounts, unmounts, and asserts the mock socket/timer is fully released—this is the check that catches real production leaks regardless of StrictMode.
Note: this mount-unmount-remount cycle is specific to React 18+; earlier versions only double-invoked render and certain lifecycles, so verify against your installed React version.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.