JSX Without React

September 11, 2023

A few years ago I wanted to know how much of "React" is actually JSX. The two get mentioned in the same breath so often that I had half convinced myself they were one thing. They are not, and the quickest way I found to check was to build a small app with JSX and no React at all. I called it not-react. It is a tiny experiment, three commits from September 2023, and I want to describe honestly what it does and what it does not.

JSX is just a function call

Browsers cannot read JSX, so something has to compile it. In my case that is Babel. The important thing is what Babel produces: every tag becomes a function call. With the "automatic" runtime, the file gets an import added for you, and this:

<Button onClick={onClick}>alert me!</Button>

becomes roughly this:

jsx(Button, { onClick, children: "alert me!" })

Nothing in that output mentions React. Babel imports jsx from wherever you tell it to. My whole Babel config is one plugin:

[
  "@babel/plugin-transform-react-jsx",
  { runtime: "automatic", importSource: "./core" },
]

With that, Babel looks for jsx-runtime inside ./core, and that file is mine. The name of the plugin says "react", but it is really a general JSX-to-function-call transform.

The function I had to write

The runtime exports jsx, jsxs and Fragment. Mine is short enough to describe in full. If the tag is a function, like Button, I just call it with its props and return the result. If it is a string, like "button", I create a real DOM element:

if (typeof tag === "function") {
  return tag(props);
}
const element = document.createElement(tag);

Then I go through the props. If a prop starts with "on" and its lowercase name exists on window (so onClick becomes onclick, which exists), it becomes an event listener. Anything else goes through setAttribute. The children, which may be nested arrays, get appended, with plain strings wrapped in text nodes. jsxs is the same function, and Fragment does the same appending into a document fragment.

That is the entire trick. A JSX expression in my app evaluates to an actual DOM node, not a description of one. That is why the app file can end with rootElement.appendChild(<App />), which looks strange if you are used to React, where JSX gives you an object and something else decides what to do with it.

The app itself

The components are about as small as they can be. Button is one line, const Button = (props) => <button {...props} />, and App renders a button that shows an alert. The build is Babel compiling src into dist, and then webpack bundling dist/App.js with an HTML template. There is nothing else in the dependencies that would be recognisable as a framework.

What is missing

Quite a lot, and my README says some of it plainly: debugging is "a bit of a pain", and the next step would be some kind of state management. Reading the runtime now, I can see why state is the hard part. A component is called once and returns a finished DOM node. There is no record of what was rendered, so there is nothing to compare against later, and no way to say "run this component again and update what changed". To get state you need something that remembers components between runs and a way to apply the difference. That is most of what a UI library is, and it is the part I did not build.

There are smaller gaps I can see in the code too. A prop like className would be set as an attribute with that literal name, which is not what the browser expects, so I would have to handle it. I have no cleanup of listeners, and nothing is typed, though the README lists TypeScript as something I would like.

What I took from it

The experiment worked, in the narrow sense I wanted: JSX does not need React. It is a syntax, and the meaning is whatever function you hand it. What React adds is everything after the function call, the part about keeping a UI in sync with changing data. I knew that in theory before, but writing the thirty-odd lines of runtime myself made it concrete. If you want to look, the code is at github.com/francomay3/not-react.