React Compiler With React 18

Jack Herrington
6 min readMay 20, 2024

--

First off, no, the React Compiler isn’t part of React 19. React 19 is just the React library. It doesn’t make any build changes for you. So to integrate the React Compiler means doing that work yourself. It also means that the React Compiler is optional. Which is good.

Just to prove the point about React 19 even more, let me show you how to use the React Compiler with a React 18 project.

Should we be using React 18 and the React Compiler together? Let’s find out!

Project Setup

We’ll use Vite for this example because the other frameworks already have React 19 and Vite currently sets up with React 18.2.0.

pnpm create vite r18-with-compiler --template react

I’m also choosing to not use TypeScript to avoid any typing issues there. You can type the c hook we create as returning an array of any if you want to.

Creating An Example

To demonstrate the compiler working let’s create some code that will show the un-optimized version first, then install the compiler and see the optimized version.

We’ll replace the App component with this implementation:

import { useState } from "react";

function Header() {
console.log("Header", Math.random());
return (
<header>
<h1>React Counter</h1>
</header>
);
}

function App() {
const [count, setCount] = useState(0);

return (
<>
<Header />
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
</>
);
}

Here we have a new Header component that just puts up a simple header. and an App component that uses the Header and has it’s own counter implementation.

In an un-optimized React component the Header will re-render every time App re-renders in response to clicking the button to update the counter.

To demonstrate this for yourself, start up the application and click on the button. Every time you click you should see the console.log from the Header component.

Optimizing With The React Compiler

The React Compiler is going to optimize our App component (and the Header too, actually) by looking to see what the Header invocation in the App depends on. And good news, it doesn’t depend on anything. So the first time we render the Header should also be the last time we render Header. The would be the optimal result of using the compiler.

What we should expect to see, if we do our jobs right is that when you click on the button the console.log from the Header will not put up a message because the Header function is not getting called.

First up we need to install the React Compiler:

pnpm add babel-plugin-react-compiler

Then we need to configure babel plugin in the Vite config. Mine ended up looking like this:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";

const ReactCompilerConfig = {
runtimeModule: "@/mycache",
};

export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", ReactCompilerConfig]],
},
}),
],
});

There are two things going on here. Most importanly we are installing and configuring the React Compiler babel plugin using the plugins section in the defineConfig . And we are configuring the compiler with the ReactCompilerConfig object.

In the compiler configuration we are saying that the compiler should get the cache memoization hook from @/mycache instead of react-compiler-runtime where it would normally get it.

We also need to set up the @ alias and point it at source. That way regardless of where the component is located it will always find our hook.

What Are We Doing Again?

Let’s take a quick step back and talk about what’s going on there. What’s happening here is that the React Compiler is optimizing our components using memoization. But it’s not using the traditional React.memo or useMemo or useCallback to do it. Instead it uses a new hook. That hook used to be called useMemoCache, now it’s called c . And the react-compiler-runtime library has that hook in it.

I’m pretty sure that react-compiler-runtime library depends on React 19. So in order to use it with React 18 we need our own implementation of the c function in that library. Turns out that function is super simple, barely even an issue. So easy in fact that here is the implementation:

import { useState } from "react";

export function c(size) {
return useState(() => new Array(size))[0];
}

No kidding. All it does is take a parameter which is the required size of the pre-allocated array, and then it returns some state associated with the component with an array of that size. So we use useState to create that array and then we return just the array.

We’ll get to how this actually works in a second, for the moment we need to save that c implementation in the src/mycache.js file (or really wherever you want to put it). Then we run the the application in and, viola, if you push the button then the Header will not re-render. Great success!

A Little Different Implementation

Another option is to basically trick the package manager into thinking that ./src/mycache is actually the react-compiler-runtime library. So you can add this to your package.json dependencies:

"dependencies": { ..., "react-compiler-runtime": "file:./src/mycache" }

And then you can remove the runtimeModule key from the ReactCompilerConfig block in the Vite configuration.

This is the official polyfill and the most up to date version is on this gist.

So Why Does This C Implementation Work?

Now that we’ve got it all rolling let’s take another quick look at this c implementation and try to figure out why it works.

import { useState } from "react";

export function c(size) {
return useState(() => new Array(size))[0];
}

So here we are creating some state and then just returning the state. We aren’t returning the state setting function, so, what the heck?

Let’s compile this component:

export default function Hello() {
return <div className="foo">Hi There</div>;
}

That turns into:

import {c as _c} from "/src/mycache.js";
export default function Hello() {
const $ = _c(2);
if ($[0] !== "a49bfc30998b8cb2...") {
for (let $i = 0; $i < 2; $i += 1) {
$[$i] = Symbol.for("react.memo_cache_sentinel");
}
$[0] = "a49bfc30998b8cb2...";
}
let t0;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t0 = jsxDEV("div", {
className: "foo",
children: "Hi There"
}, void 0, false, {
}, this);
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}

You can see at the top that the compiled code brings in that c hook and then uses it in the optimized component. The compiler knows that it only needs two slots to hold, in the first slot, the initialized flag, and in second slot the memoized version of the jsx that has the DOM tree.

So now that we know how the c hook is used we can understand a little more about why our implementation works.

First, we are memoizing stuff, and we should not be triggering re-renders of the component just by memoizing stuff. So that’s why we are not calling the state setter function. Because that would force a re-render.

Second, we are relying on the fact that we are getting a reference to the array from new Array (and thus from useState) and we can mutate the data in that array by just setting the array elements. And those mutations will be retained because the useState is holding a reference to the array and not the array contents.

If that second part is baking your noodle then I recommend this video on JavaScript memory management and how references work in relation to arrays and objects and all that.

Official Polyfill

If you are really interested in putting this into practice then check out the original source for the c function. As well as the working group article. In addition to these instructions you can also follow the official polyfill.

Go Deeper

If you want a deeper dive into the React Compiler and how all the memoization works be sure to watch my React Compiler video.

This video goes deep into the mechanics of the memoization so that you can really understand how your React component code gets converted and memoized and the granularity of that memoization.

Possible But Not Recommended

Just because you can do something doesn’t mean you should do something. That really applies in this case. The React Compiler is really designed to work within the ecosystem of React 19. So even if you can use it with 18 today, that doesn’t mean it will work tomorrow. Suffice to say, use at your own risk. As the guy says in The Hunt For Red October, “Possible, but not recommended.”

It’s possible, but not recommended

Even More Advanced Stuff

If you are into advanced React topics like this, in particular around NextJS, be sure to sign up for my ProNextJS newsletter. That will get you access to two free tutorials on NextJS state management and forms management. And you’ll get notified when the full ProNextJS course is released! It’s coming soon!

--

--

Jack Herrington
Jack Herrington

Written by Jack Herrington

YouTuber and Principal Engineer. Full NextJS course at pronextjs.dev

Responses (1)