React Compiler With React 18
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.
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.”
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!