Posted by Sean Newell on March 07, 2022 | react

Have you ever wanted to use a component, form, modal, or relatively small piece of your modern UI React codebase in one or more of your older UI codebases? At Administrate, we needed to do just that. So we developed a way to leverage and reuse our react components across codebases. This helped create consistency for new features that touched multiple UI codebases and helped our customers have the best possible experience no matter where they were.

We’ll first go into some back story about the how and why of fragmented UI codebases, give a brief overview of how React mounts and renders in the first place, and then dive right into how to approach exposing and embedding react components across disparate front end technologies and code bases.

Fragmentation

Any software company that has survived for longer than a decade will have gone through a couple (or more!) UI Refreshes. During a UI Refresh. there is excitement, ideas, and energy being poured into new designs, new technologies, and new workflows. It’s a time to embrace change and explore while providing new experiences to your users. I’ve been at enough companies and through enough UI Refreshes to know that those good intentions aren’t always translated back to the end-user in the form of a good experience, and the tech that is left behind can also leave a bad taste for future engineers.

At Administrate, we have not only gone through several UI Refreshes for our Admin Site the TMS (Training Management System), but those refreshes have often occurred during times of great change within the organization. This has left the tech fragmented across different applications and areas of our applications. Through it all, the platform has grown and matured to be based on a modern tech stack with React SPAs powered by GraphQL APIs. That new stack is our de facto standard, but there are many deviations we are still migrating over during feature development and when squashing defects.

This fragmentation has left the UI layer of the TMS with many different faces. There are areas with more traditional server-side rendered templates that send full HTML and a little bit of Javascript to the front end, there are iterations of an AngularJS powered SPA, and then there is our modern React SPA UI Layer.

The React SPA

Our React SPA is built on top of a component library that we call Piston UX. It makes use of Apollo (see our apollo cache blog post!) to talk to our GraphQL API.

We have a very standard entry point into our app, which is an index.html file that is served to provide the mount point for our application.

<div id="root"></div>
<script>
  const renderApp = () => 
    ReactDOM.render(<App />, document.getElementById("root"));
  
  renderApp();
</script>

We’ve encapsulated the call to ReactDOM.render in a function, as that will make embedding easier. The rest of the code can now be leveraged, as React will use the component tree we provide in <App /> to build out our routes and all the subcomponents needed.

The problem with our fragmented UI is that there are parts of the app that only have fully rendered HTML pages while other parts use front-end technologies such as AngularJS. Meaning some pages are not really flexible while others have control of their own root.

We needed a way to leverage smaller pieces of functionality from our new codebase without having to rewrite entire pages, as that was preventing development teams from hitting their deadlines and tackling the tech debt we’ve accrued through the UI Layer’s fragmentation. One way to do this would be to create new mount points by their own IDs and render an entirely different sub-application. We can call these Embeddable Components.

Embeddables

Exposing Components

In order to expose components for embedding, let’s create a helper type in Typescript we can adhere to. We essentially want a ‘map’ so the real host app can easily hook into a subtree of React, but we want to explicitly control which components are used:

interface EmbeddableComponents {
  [key: string]: React.FunctionComponent<any>
}

Each exposed component will have its own props, so we just pass any there for now. We can use this interface and expose a test component, so first let’s write up the test component, something simple like a card with a heading and paragraph text:

export interface TestEmbeddableComponentProps {
  name?: string
}

export const TestEmbeddableComponent: React.FunctionComponent<TestEmbeddableComponentProps> = ({ name = "Joh" }) => {
  return (
    <Card>
      <h1>Hello {name}</h1>
      <p>This component lives in our modern react powered codebase!</p>
    </Card>
  );
}

Now that we have something to test with, let’s expose it:

export const EMBEDDABLE_COMPONENTS: EmbeddableComponents = {
  Test: TestEmbeddableComponent
}

Exposing Rendering

Now that we have a component, we need some way to provide an API and alternative ‘mounting’ to host apps that have control of the page. We can’t expect that our index.html will be used, so we’ll need to add some conditional logic and a documented way to go into an ‘embed’ mode for our app. So if we extract that <script> tag back in our React HTML file, we could write:

if (shouldEmbed()) {
  embedApp();
} else {
  renderApp();
}

We’ve seen renderApp before, but how should this embedApp function work? And how would shouldEmbed work or come from? It’s important to remember we’re working in a hetereogeneous application-hosting environment. React won’t have complete control, and we won’t know exactly what capabilities are available (ie: are we embedding as a part of a ‘host SPA’? Or is it pure SSR with tiny helper scripts?). So we’ll need to design a flexible API with guardrails to allow the host application to continue working uninterrupted. Think of it as progressive embedding.

First, let’s set up some bootstrapping code for the host app to create. Bootstrapping adds the minimal required global fields on a well known shared global object (tms) to expose an API for host apps to consume. This introduces an ordering that must be adhered to, but also some safety, where the React-powered code will not try to embed unless the host page is ready for it and ‘opts in’. Here’s a snippet of that bootstrapping code:

  /** Function to create a new promise powered deferred function object */
  function deferred() {
    var promise = new Promise(function(resolve) {
      fn.resolve = resolve;
    });
    function fn() {
      promise.then(applyWith(arguments));
    }
    return fn;
  }

  var deferredRender = deferred();

  window.tms = {
    render: deferredRender,
  };

Note: if fn.resolve = resolve; looks too whacky, bear in mind that functions are objects in javascript and can be used to store information.

Second, let’s define shouldEmbed on the react side of things. Let’s check if our defered render object exists, with lots of conditional and nullable checks for good safety:

export const shouldEmbed = () => window?.tms?.render ?? false;

Now we can loosely define the embedApp() function as providing the resolution of the deferred.:

export const embedApp = () => {
  window.tms.render.resolve(render);
};

Let’s walk through that. shouldEmbed checks if window.tms.render exists, and then we use the global render function object as a deferred object that has the function resolve on it. If your mind jumped to “is that a promise?” you’re not too far off. We are resolving this promise with a local render function - that function has the real API and signature required to document for our other developers to hook into React. render is loosely defined like this:

const render = (
  mountPointId: string,
  componentName: keyof typeof EMBEDDABLE_COMPONENTS,
  props?: object
) => {
  const ComponentToRender = EMBEDDABLE_COMPONENTS[componentName] as React.FC<
    typeof props
  >;

  const mountElement = document.getElementById(mountPointId);

  ReactDOM.render(
    <ComponentToRender {...props} />
    el,
  );
};

Essentially, we need a mount point, the name of the component to embed, and any optional props to pass directly to the component.

Embedding Anywhere

The first bit of code we need is the full bootstrap script. We can write that script in our react codebase but expose it as a public/ asset just like React’s index.html. That way other apps can pull in just the /embedBootstrap.js which will bootstrap the whole embedding API for the app.

At a high-level, embedBootstrap.js not only sets up our deferred and window.tms objects, but also fetches all assets required from the react app by first fetching the manifest.json file and iterating through that list, getting all css and js files and attaching them to the body of the document one by one.

Here’s a high-level promise workflow of what embedBootstrap does:

function processAssets(assets) {
  objectValues(assets.files)
    .filter(hasLoader)
    .forEach(loadResource);
}

fetch("/path-to-react-spa-index-file") // First fetch index.html
  .then(resp => resp.text())           // We just need the text
  .then(getAssetManifestUri)           // Parse to get the URI for manifest.json
  .then(fetch)                         // Fetch manifest.json
  .then(resp => resp.json())
  .then(processAssets)
  .catch(handleError);

Now that we have the guts of the react codebase ready to be transplanted anywhere, let’s work to pull all the assets and do the setup required in the host app. Let’s imagine some legacy apps where there are some server-side rendered templates, let’s create a custom mount point for our Test component we’ve defined, and use that global deferred render function.

<!-- ... snip ... -->
<div id="custom-mount-point"></div>
<!-- ... snip ... -->
<script>
  window.tms.render("custom-mount-point", "Test", { name: "Sean" });
</script>

We can also test this by using our trusty javascript console. First, let’s manually get an ID in the DOM somewhere set up:

DOM tree with an ID

Next let’s write the same JS we would in a <script> tag (as shown above):

Script ready to embed

And boom! React anywhere:

Embedded react components on a legacy page