SOSOLOG

Where Did All Those `import React from ‘react’` Go?

February 20, 2022

Translated from the Korean original.

image-thumbnail

React 17 shipped a new JSX Transform, and this post digs into the RFC behind it to work out what that change actually means.

I’ve thrown in my own commentary and some guesswork along the way. If you’ve got feedback on any of it, a comment would be very welcome.

The Trigger

react-17

React 17 came out on October 20, 2020. I’m writing this about a year and a half later because I recently hit a React is not defined error while using esbuild, and it turned out to trace straight back to the JSX Transform change that shipped in React 17.

react-reference-error

I had this vague memory in my head: “you don’t need import React from ‘react’ anymore, starting with React 17.” Sure enough, my code had no React-import errors. But once I looked at the build output, the error made total sense.

// built js output
// references React but there's no React import
function y(V) {
// ...
return React.createElement($, ...)
}

Looking at just this much, you can piece together two things:

That caveat, ‘without extra setup,’ is the giveaway: esbuild does document a separate configuration step for React.

esbuild auto-import for JSX

Since React code compiles down to React.createElement, esbuild’s docs tell you to either add import * as React from ‘React’ to every single file, or use esbuild’s inject to handle it all in one shot at build time.

esbuild’s inject feature is basically a polyfill mechanism: it swaps out references to a global variable for an export from the injected file. You can use it to make every reference to React resolve to the react package.

react-shim.js
import * as React from 'react'
export { React }
Terminal window
esbuild app.jsx --bundle --inject:./react-shim.js --outfile=out.js

So Then…

But I’d never needed this kind of setup anywhere else I’d used React. Or more precisely, anywhere with babel already in the pipeline, it just worked without any extra config.

Although React 17 doesn’t contain new features, it will provide support for a new version of the JSX transform. In this post, we will describe what it is and how to try it.

The New JSX Transform doc that came with React 17 spells this out. A new JSX transform was built together with babel, and skipping the React import turned out to be one of its side effects.

A New Transform?

Before React 17, JSX compiled down to React.createElement.

Technically you need React 17+ and babel 7.9.0+, but babel 7.9.0 was deliberately released ahead of React 17, so chronologically, ‘after React 17’ is the more accurate framing.

import React from 'react';
function App() {
return <h1>Hello World</h1>;
}
// 👇👇👇👇👇👇
import React from 'react';
function App() {
return React.createElement('h1', null, 'Hello world');
}

The JSX → React.createElement approach had two big problems:

The new transform compiles JSX to the jsx function from react/jsx-runtime instead. And you don’t even have to reference react/jsx-runtime yourself — babel injects it for you at build time.

function App() {
return <h1>Hello World</h1>;
}
// 👇👇👇👇👇👇
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}

Flip @babel/preset-react’s runtime between automatic and classic on babel/repl and you can watch the build output change accordingly.

[RFC] A Proposal for a New JSX Transform Approach ( PR1 / PR2 )

Back in 2019, Sebastian Markbåge wrote an RFC arguing that createElement needed to change. It proposes simplifying how React.createElement behaves, with the eventual goal of removing the need for forwardRef entirely.

What follows trims and edits parts of the original RFC.

History

React 0.12 changed how key, ref, and defaultProps behave in a big way. All three now get evaluated before React.createElement(...) is even called.

React.createElement made perfect sense back when class components dominated. Once function components took over, it became something worth reconsidering.

Here are some of its bigger downsides:

Performance aside, the new JSX Transform also exists to lower the bar for actually understanding React. Cutting the need for things like defaultProps and forwardRef, and pushing JSX toward being more standardized, both mean shaking off React’s more arcane legacy behavior.

Proposal 1. Auto-import

First on the list: get rid of the rule that “React has to be declared somewhere in scope for JSX to work.”

Ideally, element creation would just be part of the transpiler’s own runtime. But that raises a couple of concerns.

First, React splits into a DEV mode and a PROD mode, and DEV mode is far more complex and far more tightly coupled to React than PROD mode is.

Second, it’s simply easier for users to adopt a change through a new react package release than through updating their build tooling.

For both reasons, the actual implementation still has to live in the react package. Here’s the shape the RFC proposes:

function Foo() {
return <div />;
}
// 👇👇👇👇👇👇
import {jsx} from "react";
function Foo() {
return jsx('div', ...);
}

Proposal 2. Split key Out of Props

Before, key was excluded from this.props, but React.createElement still bundled it in with the rest of the props as just another argument. The new jsx approach separates it out into its own argument instead of passing it alongside props.

jsx('div', props, key)

The RFC doesn’t spell out its exact reasoning here, but it reads like a mix of easier maintenance and a general push to keep props and key more clearly separated.

Proposal 3. Always Pass children as a Prop

createElement passed children as variadic arguments. The new approach just always tucks it into props instead.

The old variadic style existed so DEV mode could tell static children apart from dynamic ones.

(Speculation) “So DEV can tell them apart”?
What the RFC author probably means by “distinguishing static children from dynamic children in DEV” is the warning DEV mode throws when dynamic children — array-type children, that is — don’t have unique keys. Look at L462 ~ L479 of createElementWithValidation and you’ll find logic that validates key based on the type of arguments it received.

The new proposal looks like this:

<div>{a}{b}</div>
// 👇👇👇👇👇👇
jsx('div', { children: [a, b] })
<div>{a}</div>
// 👇👇👇👇👇👇
jsx('div', { children:a })

Proposal 4. A DEV-only Transformer

In DEV, attributes like __source and __self skip props entirely and get passed as their own separate arguments. The fix here is just splitting off a dedicated DEV function.

// function used only in DEV
jsxDEV(type, props, key, isStaticChildren, source, self)

Proposal 5. Deprecate defaultProps on Function Components

Honestly, function components never actually needed it in the first place.

Proposal 6. Deprecate key Spread

let randomObj = {key: 'foo'};
let element = <div {...randomObj} />;
element.key; // 'foo'

There’s no static way to tell whether key got passed in, so it always needed a dynamic lookup. Since it can’t be analyzed statically and that lookup isn’t cheap, key spreading is no longer supported.

Proposal 7. Move ref Extraction to Class, forwardRef Render Time

This changes how ref gets handled under the hood. A minor release starts warning on any access to element.ref. The next major release copies ref onto both props and element.ref, but React then switches to treating props.ref as the single source of truth for forwardRef.

Wrapping Up

Writing this up, what stuck with me wasn’t just the technical content itself. It was watching how much thought went into shaping a change the ecosystem could actually absorb, and how it landed without asking React’s end users, meaning the developers actually using it, to pay any cost at all.

A few other things I found interesting while going through the RFC and its related updates, to close this out.


SO_YOUNG
👋@SO_YOUNG
📝 A small, casual dev log

GitHubX (Twitter)