February 20, 2022
Translated from the Korean original.
![]()
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.

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.

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 importfunction 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.
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
Reactresolve to thereactpackage.
import * as React from 'react'export { React }esbuild app.jsx --bundle --inject:./react-shim.js --outfile=out.jsBut 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.
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:
import React from ‘react’ line, no exceptions.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’sruntimebetweenautomaticandclassicon babel/repl and you can watch the build output change accordingly.
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.
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.
someElement.props.key → someElement.keydefaultProps now resolves at mount time instead of when the ReactElement gets created, meaning it’s evaluated earlier than the rest of the props.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:
A component with defaultProps forces a pile of dynamic checks, since defaultProps can hold basically anything, and that makes it hard to optimize around.
defaultProps doesn’t work with React.lazy, so you always have to check whether it even exists, even during the render phase.
children gets passed to React.createElement as variadic arguments, which then have to get dynamically patched back onto props.
Passed as variadic arguments looks like this:
<Foo bar="bar"> <div>hi</div> <div>hi2</div></Foo>
// 👇👇👇👇👇👇React.createElement( Foo, { bar: 'bar' }, React.createElement('div', null, 'hi'), // no argument at all if <div>hi2</div> isn't present React.createElement('div', null, 'hi2'),)Anything built on React.createElement needs a dynamic property lookup.
There’s no guarantee the props you receive are immutable, so you always have to clone them before touching them internally.
key and ref have to be stripped out of props, so if they somehow end up in there, something has to delete them.
A pattern like <div {...props} /> means checking, every single time, whether key or ref snuck in.
Since the output takes the shape of React.createElement, that output always has to carry a React import. Ideally, using JSX shouldn’t require importing anything at all.
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.
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', ...);}key Out of PropsBefore, 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.
children as a PropcreateElement 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 ofargumentsit received.
The new proposal looks like this:
<div>{a}{b}</div>// 👇👇👇👇👇👇jsx('div', { children: [a, b] })
<div>{a}</div>// 👇👇👇👇👇👇jsx('div', { children:a })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 DEVjsxDEV(type, props, key, isStaticChildren, source, self)defaultProps on Function ComponentsHonestly, function components never actually needed it in the first place.
key Spreadlet 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.
ref Extraction to Class, forwardRef Render TimeThis 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.
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.
children as dynamic positional arguments existed purely to support a DEV warning check.
@key… but didn’t go with it, since it would have inflated the change unnecessarily. (Probably judged the benefit wasn’t worth the extra size.)