Beating Up React for Fun (and Profit?): Using React to Drive Another Component Tree
React is a clever user interface library designed around a very functional paradigm, where the input is some state (represented as properties) and the output is a tree of renderable user interface components (represented as, usually, something that can be translated to another UI representation, such as an HTML element tree).
That’s all well and good, but what if the render tree is already established, but you want to use React to drive updates to it?
In this post, I’m going to go over a problem I had of this nature that I was able to solve, the solution, and why I decided to do this as opposed to alternatives.
The problem
I’m working on a small cockpit visual for the Lancer RPG. It’s basically a glorified turn-counter, since the game has a lot of options you can do every turn with various costs and constraints; I’m finding it easier to keep track of it all if I can see it all at once.
To render the cockpit, I’ve used Inkscape to create an
SVG, using IDs to indicate various active components (the joystick, dashboard
icons, the various button panels, etc.). I then render that SVG at the top of my index.html
file:
<object id="dashboard" type="image/svg+xml" data="cockpit.svg"></object>
<div id="root"></div>
The tricky bit is this: I’d like to use React to handle keeping all the UI state updated (i.e. responding to clicks on buttons, disabling buttons that can’t be used because the pilot is out of actions, etc.). But I don’t want to use React to keep track of the actual structure / hierarchy / positioning of the UI; that should come from the SVG alone. There are ample examples of how to use React portals to inject React-controlled renderables into other pieces of the DOM, but not very many examples of how to use React to capture and control some existing DOM component.
The solution: it’s just effects
The solution I discovered was to just use the built-in DOM manipulation APIs to
fetch the SVG components and then manipulate them in React using useEffect
. In
essence, my SVG-controlling components:
- Fetch the SVG components directly via DOM manipulation (this is safe because in my use case, SVG DOM elements are neither created nor destroyed)
- Mutate the SVG components in
useEffect
- Return
null
because they don’t actually want to add anything new to the DOM.
Here’s an example component that controls a dashboard icon in the UI:
import { useEffect } from "react";
export interface DashIconProps {
status: boolean;
onClick: () => void;
componentId: string;
}
export const DashIcon: React.FC<DashIconProps> = ({ status, onClick, componentId }) => {
// Grab a reference to the SVG element to control
const component = (document.getElementById('dashboard') as HTMLObjectElement).contentDocument!.getElementById(componentId)!;
// Update component's color
useEffect(() => {
const strokeColor = status ? '#FFFF00' : '#666600';
for (let i = 0; i < component.children.length; i++) {
(component.children[i] as SVGPathElement).style.stroke = strokeColor;
}
}, [status]);
// Update component's click handler
useEffect(() => {
const clickHandler = () => onClick();
component.addEventListener('click', clickHandler);
return (() => component.removeEventListener('click', clickHandler));
}, [onClick]);
return null;
};
This component has three main parts:
Grabbing a reference to the SVG element
Because in my application the SVG elements aren’t created or destroyed, they’re
always accessible by ID. So fetching them using a simple
document.getElementById
call is sufficient. Note that the fetch has to recurse because the whole SVG image is loaded as an <object>
and therefore has its own content layer; we indirect through contentDocument!
to get to it and can then access its contents.
Update the color
We hook useEffect
off a change to the status
to draw the icon bright or dim
depending on if its enabled or disabled. The loop through component.children
is necessary because we actually fetch the group above them as the element with the given componentId
.
This approach lets us manipulate any SVG state that can be accessed via the DOM API, which I’ve used to change stroke and fill color and even update the text on the turn counter (top) and move counter (left).
Update the click handler
Should the click handler change, we need to attach it to the SVG element we’re controlling. We do this, and additionally return a callback to remove the click handler when this effect needs to be reversed (this way, we don’t leak click handlers or, worse, have a “stale” click handler still trigger effects we don’t want triggered).
Return null
Finally, since we don’t actually want to render any components of our own, we return null. We can also return nested subcomponents to build a more complicated controlled object (such as a joystick and the buttons on the joystick), but ultimately those won’t be rendering components either so it’s irrelevant whether we nest or just keep all the controls top-level.
Using the components
Our control components are put in a larger app context component and name the SVG elements they control.
// excerpt from Dashboard.tsx
return <React.Fragment>
<DashIcon status={movePoints > 0} onClick={() => setMove(!move)} componentId={'move-arrow-icon'} />
<DashIcon status={reaction} onClick={() => setReaction(!reaction)} componentId={'reaction-icon'} />
<!-- additional controlling components -->
</React.Fragment>
Binding to the SVG layer (the part where whip cracking sounds happen)
Finally, we activate all of this by creating a callback that can be triggered once our SVG object is loaded that fires off the React layer.
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
(document as any).bootUp = () => {
const rootElement = document.getElementById('root')!;
const root = createRoot(rootElement);
root.render(<App />);
};
Finally, we do one truly hackish thing: there’s no guarantee that the JavaScript
containing our React controller components loads before the SVG object finishes
loading, so when the SVG load handler tries to call bootUp
, it might not
exist. There are better ways to solve this, but I’ve spackled over it by calling
bootUp
on a timeout. More robust solutions exist (such as having the onload
logic register a callback in document
and try to call bootUp
if it exists,
meanwhile the React script fires that callback if it exists, which will call
document.bootUp
, otherwise it registers itself in document
and goes on). But
this solution gets me off the ground for the prototype.
<object id="dashboard" type="image/svg+xml" data="cockpit.svg"
onload="setTimeout(() => document.bootUp(), 500)"></object>
<div id="root"></div>
The overall approach works pretty well. Here’s the results of using the cockpit after wiring several buttons and rules together.
Why bother to do this?
Is this the right way to use React? No. This problem is ill-suited for React. It’s probably not the right framework of choice. React “wants” to own the render tree, and gluing it loosely to some other tree like this is fraught. Any criticism of (ab)using React this way is wholly justified.
However.
While I believe in using the right tool for the job, it is ironically that belief that led me to abuse React here in the first place. I didn’t want to build out the UI as a collection of disjoint SVG pieces that then had to be rendered as React subcomponents and manually glued together with CSS tweaks. Inkscape is the right tool for the job of creating an SVG image, so I wanted all the layout responsibility to live there and there alone.
That having been said, there are some downsides.
- A missing SVG element ID will crash the UI (we could easily make it more robust, but since a missing ID is a bug, I want it to fail fast).
- Anything that actually changes the structure of the SVG (espeically adding or removing elements) would significantly complicate the whole mechanism.
- The order-of-loading dance between SVG object and React scripting isn’t paricularly sexy.
But apart from those issues, I’m finding this a pretty clean way to manage some complex state dances without having to give up on what React is good at: treating state change as the evaluation of functional logic instead of the toggling of stateful UI elements. I like how this approach lets me take React’s functional approach and apply it to other pieces of the declarative, stateful web UI.