Thoughts on React 17

9 minutes read · 2020-09-10

On August 10 Facebook released version 17.0.0-rc0 of the React JavaScript framework. This is a pretty unusual React release in that it has fundamentally no new features. Instead, it contains a very small set of breaking changes designed to facilitate the migration to future versions of the library.

3 years of React 16

Given that the JavaScript ecosystem typically moves at breakneck pace, it’s almost impossible to believe that the 16.x line of React releases has now been going on for almost three years, with full backwards compatibility:

From 16.0 to 16.13.1, the experience of using React was completely transformed: A lot of features that were previously experimental or unergonomic to use got first-class support (Like portals and context) and we went through a lot of trials in composability, by means of Higher Order Components, Render Props and ultimately Hooks in 16.8.

What was once a Class-based object-oriented UI framework is now a Pure-functional, algebraic effects-inspired scheduler for arbitrary computation, that yeah, is still pretty good for building UIs.

And all of that without breaking backwards compatibility: Assuming you were not using private or experimental APIs (and were not depending on a library that did it) the same project written for React 16.0 should still run under React 16.13.1, with maybe a couple warnings.

Preserving that compatibility is one of the biggest accomplishments (and selling points) of React as a framework, and it makes sense: Facebook has a massive codebase with thousands of components and pages that it needs to maintain, so API stability is a must.

All of that comes at a cost, though. Some of the highly anticipated features (like Concurrent mode and Suspense for data fetching) ended up taking way more work than expected, and will require breaking backwards compatibility. React 16.9 already deprecated a bunch of lifecycle methods and saw them marked as UNSAFE_ for concurrent mode. It looks like the React team has decided it’s finally time to move on, and start shedding support for those deprecated methods for good.

Before they can do that though, they’re introducing an escape hatch for legacy codebases that can’t upgrade, in the form of React 17, by officially supporting a workflow where multiple versions of the React framework can more easily coexist in the same page side by side, and even nested “inside” each other. Unfortunately, getting that to work properly requires some breaking changes, albeit very small ones.

Breaking Change: Event Delegation

Events in HTML documents traditionally propagate in two phases: Capture and Bubble. When you, for example, click on a div, the click event fires repeatedly from the document root all the way down the to the clicked element, and then back up again the DOM node hierarchy. That gives you a lot of flexibility to put your event handlers at whatever level makes the most sense for you, and intercept incoming events before nested (or parent) elements are able to handle them.

React for the most part has bypassed this mechanism, by adding listeners at the document root, and implementing their own capture and bubbling logic internal to the framework, a technique known as event delegation.

This becomes an issue once you try to nest components running in different versions of React inside one another, since you can no longer intercept events correctly via e.stopPropagation().

React 17 fixes this by moving the event listeners from the document root to the React root (the element on which you call React.render() on) which allows interception to behave as expected, while still using event delegation.

This can break code that added custom event listeners directly to rendered DOM elements via refs, and relied on precise ordering to work. That shouldn’t be that common (Facebook reported having to change only 10 out of thousands of components) but is technically a breaking change that requires a semver major bump.

One interesting implication of this is that it’s now also easier to properly integrate React trees into codebases written on other frameworks, facilitating framework migration. The announcement blog post even mentions a hypothetical scenario of migrating an app’s code base from React to jQuery, which I thought was kinda humorous.

Breaking Change: useEffect hook cleanup timing

The cleanup function of a useEffect hook will now run asynchronously, only after the component has been unmounted and the rendering of whatever content is replacing it is completed.

In practice this should speed up the visual transition between “heavy” component trees, like navigating between tabs or pages, since you’re not blocked on cleanup before you can render the new UI. However, it might break also some code that relies on refs still being set while running cleanup functions. If that’s the case, you’ll now need to hold the ref’s current value in a variable inside of the useEffect hook callback.

For example:

useEffect(() => {
  someRef.current.someSetupMethod();
  return () => {
    someRef.current.someCleanupMethod();
  };
});

Becomes:

useEffect(() => {
  const instance = someRef.current;
  instance.someSetupMethod();
  return () => {
    instance.someCleanupMethod();
  };
});

If for some reason you absolutely need the old synchronous behavior, you can always switch to the useLayoutEffect() hook.

Breaking Change: Goodbye Event Polling

If you’ve ever been bitten by forgetting to call e.persist() on an event object you wanted to hold on to, you’ll be happy to know event pooling is now also gone. It was originally introduced as a performance optimization for older browsers but is no longer needed, and actually caused a couple hard-to-find bugs.
So I’m happy to see it removed.

React 17 also introduces a couple other breaking changes related to events, including:

The sneaky ‘feature’: Component Stack trace improvements

A very welcome addition to React 17 that I would personally consider to be a feature, is the new mechanism for producing Component stack traces.

Previously, Component stack traces didn’t have file or line number information available on them. This was mostly due to there being no mechanism in JavaScript to get that info for any given function.

Sebastian Markbåge from the React team has figured out a very clever workaround to that limitation, by re-invoking all of the component renderer functions all the way up to the root, and purposefully causing them to throw, catching the errors, extracting the line and file information and re-assembling the component stack trace.

What that means is that you should now be able to click on the stack traces from your browser to more easily find that broken component, and even have the lines properly mapped for obfuscated production builds (via source maps).

The Sample Project

Dan Abramov has published a sample project demonstrating the setup needed for integrating two separate versions of React in the same codebase, and even passing context data between them.

It doesn’t require any special transpilation or bundling steps (and in fact runs un-ejected on create-react-app) but it does rely on some package management trickery to work.

I personally found the setup to be a little bit harder to follow and fully understand than I had originally expected, and there’s definitely some caveats to it.

Probably for that reason, the React team doesn’t recommend doing this unless absolutely necessary, and even so, they strongly suggest lazily loading the legacy framework versions asynchronously and only when needed, to minimize performance impact. Which brings me to my concern with this release.

Why I’m Concerned

Historically, we as the JavaScript community haven’t been really consistent at properly taking advantage of advanced bundler features, like code splitting, tree-shaking and hot-reloading, for reasons both technical and practical.

Technical, because some of these features are often hard to set up properly, and once set up they’re easy to accidentally break. For instance, one wrong auto-import from your IDE can cause your bundle size to grow dramatically.

Practical, in the sense that at the end of the day those of us that are professionals in this industry work on shipping products, with real world constraints and deadlines. Not all companies can afford to have someone full time taking care of the Developer Experience, so compromises sometimes need to be made.

Our machines and internet connections are also typically much better than those of our users, so issues that degrade performance can go unnoticed for a long time.

I’m worried that officially supporting this multiple-React setup might eventually lead us to seeing 5 or 10MB bundles with three or more versions of React statically compiled together.

I’m especially worried about the possibility of library authors choosing to bundle their own React versions into their libraries, instead of having React as a peer dependency.

There are some precedents of this problem, for instance some WordPress plugins, rely on jQuery’s noconflict mode to properly work together. Transitive dependencies could make this issue more opaque for React projects.

With that said, the React team has so far been really good at providing a clear upgrade path for the ecosystem, so I trust that they took this issue into consideration, and that therefore this has been a calculated decision.

It’s now up to us to also be mindful of this, and avoid causing this bloat and fragmentation on our codebases, by making proper use of code splitting, and sticking to a single framework version whenever possible.

React 18 and Beyond

The backwards compatibility constraints have now been lifted after three years of “dynamic stability” with the ecosystem, where we got new features “for free” without having to change existing code.

I expect the React team to shortly follow the 17.0 release with one or more additional major releases, possibly in relatively rapid succession.

This is just a hunch, but it wouldn’t surprise me to see another Roadmap update post on the Blog soon, as a follow up to the two previous ones and to the post we got late last year about React Pre-releases. Perhaps after the breaking changes that will enable the long awaited features, (Suspense and Concurrent mode) we’ll get another long-running series of backwards compatible releases, maybe on React 20.x.


Note: This article is adapted from this video. If you enjoy this type of content also in video form, you might like my YouTube channel.