Most developers are familiar with using React within the context of a full application. Facebook's Create React App project makes it super easy to run one with just one command.
But what about building on an existing React app?
The extensions I develop on RoamJS are meant to be injected and run on an existing React application. This led me to start developing them using plain JavaScript and DOM manipulation. As expected, this approach began to scale poorly when the types of extensions I wanted to build became more ambitious. Which had me asking, "Could I build Roam extensions using React?"
Turns out, the answer was a resounding yes! The first extension I released with React was this Query Builder.
In this article, I will go through how to integrate React scripts into an existing React app.
Building on Top Of React
There are a couple of gotchas to consider when injecting any script into an existing React app.
State Management
React components manage their own state and re-render new DOM elements based on that state. This means you cannot edit input values without them getting overridden by the app's state management under the hood.
For example, let's say there's a text area that you want to write a new value to. You might be tempted to do something like the following:
const textarea = document.getElementById('textarea1');
console.log(textarea.value); // "Old Value"
textarea.value = 'New Value';
The problem is the next time you interact with that text area, it will just override 'New Value' with whatever value React is managing under the hood, which most of the time is "Old Value".
To solve this, you need to simulate user interactions on the DOM elements. An interesting library I used to do this is Testing Library's User Event, which provides a bunch of helpers around common user interactions. It's primarily meant for testing, but it could also be used for simulating user actions in general.
It's also possible to simulate these events manually too. For writing data, you often want to dispatch a keydown
event, followed by a keyup
event:
const downEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
keyCode: 65, // a
});
const upEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
keyCode: 65, // a
});
const textarea = document.activeElement;
textarea.dispatchEvent(downEvent);
textarea.dispatchEvent(upEvent);
Clicking and focusing on an element is easier, as the HTML DOM elements support these operations.
const button = document.getElementById('button1');
button.focus();
button.click();
Unmounting
When React re-renders a sub-tree in the DOM it essentially replaces all of the DOM nodes in that part of the tree with new DOM nodes. This means updates to a DOM element with the same id could lose all attributes, children, or listeners that you attached to it.
Let's say you have an icon that you want to display as a child of some element with id parent
. You might be tempted to run this on load and call it a day:
const icon = document.createElement('span');
icon.innerText = '😃';
const parent = document.getElementById('parent');
parent.appendChild(icon);
The problem is if that element's props or state change for any reason, React will unmount the old version and replace it with a new element. This new one will no longer have your smiley icon.
To solve this, use MutationObservers to detect whenever elements that you are interested are added to the DOM and render based on that.
From the example above, we want to detect whenever an added node is or contains the element we're looking for.
const parentId = 'parent';
const icon = document.createElement('span');
icon.innerText = '😃';
const observer = new MutationObserver((mutationList) =\> {
mutationList.forEach((mutationRecord) =\> {
mutationRecord.addedNodes.forEach((node) =\> {
if (node.id === parentId) {
node.appendChild(icon);
} else if (node.querySelector(`#${parentId}`)) {
node.querySelector(`#${parentId}`).appendChild(icon);
}
})
})
});
// The first argument is a target. Try to scope it down as much as possible
observer.observe(document.body, { childList: true, subtree: true });
Now, no matter what funny business React is doing under the hood, we are always watching it ready to make the DOM changes we need.
Event Listeners
React attaches all event listeners to the document's root (as of React 17, they are attaching to the React tree root). This means if you add event listeners to elements directly, you could guarantee their invocation before they propagate up to the app's React components.
If you want some custom listener to override whatever your host application is doing, be sure to end the event propagation at the end of your function.
const button = document.getElementById('button1');
button.addEventListener('click', (mouseEvent) =\> {
// do stuff
mouseEvent.stopPropagation();
mouseEvent.preventDefault();
})
This comes with the caveat of the previous section. Beware of the possibility that your event listener could be unmounted at any point and be ready to attach accordingly. An alternative for those who don't mind their host's default event behavior is to attach from the document level.
document.addEventListener('click', (mouseEvent) =\> {
if (mouseEvent.target.id === 'button1') {
// do stuff
}
})
These are all things to keep in mind when building on top of an existing React app. They are relevant regardless of what framework you choose to build with. Now let's talk about some things to keep in mind when building extensions in React.
Extensions in React
Since modern React tooling solve most of the "Hello, World" type problems, you have to duplicate some of those steps to build an extension in React. Here are the primary steps.
There are a few common dependencies you will need which are similar to most standard React apps.
npm install --save react react-dom
npm install --save-dev @babel/core @babel/preset-env @babel/preset-react @types/react @types/react-dom babel-loader ts-loader typescript webpack webpack-cli
First, I have a simple .babelrc
file at the root.
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
Then, I have the following Webpack config.
module.exports = {
entry: './src/index.ts',
resolve: {
modules: ["node_modules"],
extensions: [".ts", ".js", ".tsx"],
},
output: {
path: path.join(__dirname, "build"),
filename: "[name].js",
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "babel-loader",
options: {
cacheDirectory: true,
cacheCompression: false,
},
},
{
loader: "ts-loader",
options: {
compilerOptions: {
noEmit: false,
},
},
},
],
exclude: /node_modules/,
},
],
},
};
If you prefer to work in just plain JavaScript, simply remove the ts-loader
and add .jsx
to extensions
. If you are bundling multiple extensions, then you want to change entry
to be a map of script name to the source file. For simplicity, I keep all of my extension entry points in the same directory so that I could build this mapping dynamically.
const extensions = fs.readdirSync("./src/entries/");
const entry = Object.fromEntries(
extensions.map((e) =\> [e.substring(0, e.length - 3), `./src/entries/${e}`])
);
/*
entry: {
foo: './src/entries/foo.ts',
bar: './src/entries/bar.ts',
}
*/
Now that we have our build dependencies and configuration ready, we could jump into source code.
React does not need to have control of the entire DOM. It only controls what is within its given sub-tree. Their documentation has a great explanation of how powerful this approach is in migrating from older libraries to React.
One approach is that you could take over an existing node. Note this means that your React sub-tree will overwrite whatever the app is doing, unmounting the existing nodes. This is useful for when you just want to replace the host's component with your own.
 import ReactDOM from 'react-dom';
 import React from 'react';
 import NewComponent from './NewComponent';
const parent = document.getElementById('parent');
ReactDOM.render(<NewComponent /\>, parent);
// Everything that used to be children of parent is now gone
The second approach is to add a new element to act as your React root. This is useful for when you simply want to overlay new functionality without disrupting whatever the host is currently rendering.
 import ReactDOM from 'react-dom';
 import React from 'react';
 import NewComponent from './NewComponent';
const parent = document.getElementById('parent');
const root = document.createElement('div');
parent.appendChild(root);
ReactDOM.render(<NewComponent /\>, root);
// Everything that used to be children of parent is still there
With both approaches, you still have to keep in mind that at any point, the parent's props or internal state could change causing a re-render. This will unmount your component for good, requiring you to have to observe for changes if you would like to keep it mounted on the DOM.
Improvements To Make
I was thrilled to find out that React is extensible enough to be easy to use on top of existing apps. There are still ways I think these workflows could be made easier.
Host applications could provide hooks into their internal React state to modify desired components safely instead of relying on simulating user events, which could be very prone to error.
Host applications could also emit common event types that extensions could listen in on so that scripts are not degrading application performance with Mutation Observers.
Finally, if both the extension and the host are written in React, there should be a way to expose React within the context of the script being run. This would allow extension developers to be able to build and share new functionality without having to bundle both React and ReactDOM, which take up a considerable amount of space.
For more on how I solve extension-related problems, be sure to check out the RoamJS repo on GitHub. For anything else, feel free to DM me on Twitter!