React 17 shipped in October with an announcement titled “No New Features”, which is unusual enough to be worth reading carefully — because it does change two things, and one of them breaks any page where React coexists with something else.
What it is actually for
the problem it solves is upgrading a large application.
before 17, React is a singleton per page: one version, and
upgrading means upgrading everything at once. on an app with
400 components and three teams, that is a release nobody
wants to be responsible for.
17 makes gradual upgrades possible: the root of the app on
17, a lazily-loaded section on 18, both on one page.
so 17 is not a version you get features from. it is the
version you pass THROUGH to make the next one tractable.The value is entirely deferred, which makes it a difficult release to justify on its own terms and an easy one to skip. Skipping it means the next major upgrade is the all-at-once release again.
The change that breaks things
// React 16: every event handler is delegated to `document`
// React 17: they are delegated to the ROOT CONTAINER
ReactDOM.render(<App />, document.getElementById('root'))
// ↑ handlers now attach to #root, not document
// so this, which worked, stops working
document.addEventListener('click', (e) => {
if (!menu.contains(e.target)) closeMenu()
})
// in 16: a React onClick called stopPropagation() and the
// document listener never fired — because React's
// handler ran at document level, first.
// in 17: the React handler runs at #root, the event still
// bubbles to document, and the menu closes.
Any code relying on stopPropagation inside a React handler to prevent a document-level listener from firing changes behaviour, and the direction of the change is that more listeners fire rather than fewer. A close-on-outside-click implementation is the canonical case and there is one in almost every application.
The new behaviour is the correct one — it is what the DOM does — and it means the previous code was relying on an implementation detail of React’s delegation. That does not make the migration less real.
# what to search for
$ grep -rn 'document.addEventListener' src/ | wc -l
14
$ grep -rn 'stopPropagation' src/ | wc -l
23
# of those, 4 were the pattern above. all were dropdowns.// the fix, which is also the better implementation
useEffect(() => {
const onPointerDown = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
close()
}
}
// capture phase, so it runs before React's handlers
document.addEventListener('pointerdown', onPointerDown, true)
return () =>
document.removeEventListener('pointerdown', onPointerDown, true)
}, [close])
Using the capture phase makes the behaviour explicit rather than dependent on where React attaches, so it works on both versions and will keep working. Switching from click to pointerdown also fixes a separate long-standing bug where the menu stayed open through a drag.
The rest of the list
onScroll no longer bubbles it never should have. this
fixes more than it breaks.
onFocus/onBlur now use closer to the native events;
focusin/focusout behaviour is nearly identical
no event pooling e.persist() is a no-op. every
async handler reading e.target
after an await now just works.
react-dom/server: no useEffect cleanup on unmount is
cleanup on the server consistent now
removed: private APIs that if you used them you are not
nobody should have used reading this sectionThe removal of event pooling is the one change that is unambiguously a feature, and it is filed under cleanup. Reading e.target inside a setTimeout or after an await used to return null and require e.persist(), which was the most common React bug in any codebase doing anything asynchronous in a handler.
The gradual upgrade, if it is actually used
// the legacy part of the app, on 17
import ReactDOM from 'react-dom'
ReactDOM.render(<LegacyApp />, document.getElementById('legacy'))
// a new section, lazily loaded, on its own React
const NewSection = lazy(() => import('./new-section'))
// which bundles its own react and react-dom
and what that costs, which is the reason it is rarely done:
two copies of react in the bundle ~40 KB gzipped
no shared context across the boundary
no shared state — the two trees are unrelated
two sets of devtools
so it is a MIGRATION tool, used for a quarter, not an
architecture. an application still doing this a year later
has a different problem.The context boundary is the constraint that decides whether this is usable at all: a theme provider, a router or a store in the outer tree is invisible to the inner one, so the split has to fall where those are not shared. On most applications that is a small number of places and they are not arbitrary.
Verifying it worked
$ npm i react@17 react-dom@17
$ npx jest
Tests: 412 passed
# which proves very little, because jsdom does not exercise
# real event delegation faithfully. the browser tests are
# what matters here:
$ npx playwright test
38 passed, 4 failed
✗ dropdown closes on outside click ← the change
✗ modal closes on backdrop click
✗ tooltip dismisses on scroll
✗ autocomplete blurs on outside clickFour browser test failures and zero unit test failures is the shape of this upgrade, and it is worth expecting — event delegation is precisely the thing jsdom approximates. An application without browser tests will find these in production instead.
All four were the same pattern and the same fix, which took an afternoon. The upgrade is genuinely small; it is small in a way that is invisible to the test suite most teams have.
What this costs
An afternoon, and a version bump with nothing to show anyone. That is the real difficulty with React 17 — there is no user-visible improvement to point at, so it competes badly for time against work that does something, and it is exactly the sort of upgrade that gets deferred until it is bundled with the next one and stops being small.
The gradual upgrade capability is also easy to overvalue. It works, it costs forty kilobytes and a context boundary, and it is only worth using on an application large enough that a single-shot upgrade is genuinely unmanageable. For most applications the honest reason to take React 17 is that it makes React 18 an ordinary upgrade rather than a project.