Integrating React into any existing system

You can embed react applications into any existing system using `render` and `portal` functions to render the apps into target elements. Find out how!

Jannes Mingram Jannes Mingram 11 min read

TL;DR: Jump to the implementation.

Summary

It is hard to re-do your entire stack at once. Using the top-level render API of react (and any other alike framework), it is easy to embed modern, new applications into an old, legacy stack without needing to re-implement the entire thing. These new applications remain programmable standalone and can be interacted with from both old code and between each other.

The key to implementing such a pattern is to use the render and portal functions to render the apps into target elements. Te elements in turn can be made placeable in CMS and other legacy systems using plain html-templates. An entry-point script then scans the entire page for such html-templates and starts their respective applications using portal within.

Overall, this pattern promises easy and seamless integration of old and new and is the solution to the common legacy-stack problem.

Why even bother?

Context

I am a big fan of web applications, e.g. in react or svelte. However, most websites are not created from scratch. I experience this first-hand whenever I start a new project or talk to yet another customer.

This leads inevitably to conflict: As an experienced web-developer, I know about the advantages of using a modern stack; many developers, I've led, roll their eyes when they hear jQuery. "But where's the business-value!", I hear the client ask. Sounds familiar?

This article will not focus on "How do I convince the business to use a more modern stack" and neither will it answer "How do I migrate my legacy application to a modern stack". The answers to those are "It depends!" (For more details, contact me.)

Instead, we are going to explore "the middle way":
How can I integrate a web-application into my existing software, project or stack?

Web-applications

A web-application (for our purposes) is an interactive application on a website that interacts with a backend using JavaScript (e.g. Ajax, fetch). Related are the SPA and the concept of JAM-stack. Often a web-application includes multiple functionalities, which each are encapsulated in their own app.

Web applications are rather "new" - the technology that enables web applications is fresh compared to the technology innovation lifecycles within companies, which is usually between 5-20 years. This can be seen in Google Trends.

Commercial applications

Some websites are "online-first" and their website is their product; examples include google, PayPal, twitch, AWS or Shopify. These websites frequently have complex applications maintained by different teams and in the end combined on the same web-page. Switching technologies typically requires their entire product to be re-written - and therefore they shy away.

For some websites, the website is the product

Typically, however, most commercial websites are for offline goods: They advertise and offer to buy products and services, such as consumer-goods, cars, electronics, train-tickets and flights, carpenter-, gardener- and legal services or electricity. These sites most typically use a CMS (advertisement) or web-shop (sales). This second type of website is largely more common! (If you don't believe me, try to list all "online-first" websites that come to mind - then list all airlines and car-brands you know; each of those has their own website!) This means, every new functionality must fit into their existing CMS or web shop.

For most websites, the website sells or advertises their products and services

Motivation

With the rise of web applications, their integration has become much more common. There are a couple of different ways to integrate web applications on existing websites.

Landing pages

Landing pages are standalone websites (often SPAs) - they can be guild as green-field applications in complete isolation. They are usually quickly pulled down again as the campaign they've been build for reaches its end. (More information.) Their district disadvantage is that they are, by design, not integrated into the existing website.

Re-write

Bigger companies sometimes rewrite their entire website - this is another greenfield approach. This approach takes a lot of time and resources and is frequently not viable.

Integration

Websites are composable. Living proof of these are all the SDKs out there ("paste this code-snippet to use our service on your website"). This approach is gradual, modular, flexible and scalable. These advantages, however, come with technical complexity. This article aims to take the technical complexity out of the challenge and provide an easy, but universal way to perform this integration.

Scenarios

This article describes how to integrate custom web applications (such as react-applications) into common CMS software (such as WordPress, Drupal, Ghost, AEM) and web-shop technology (such as Shopify).

There are different levels of integration we will consider. The approach we are covering allows to integrate multiple different applications into a website. Further, each application should be placeable by an editor/author (a non-technical employee who looks after the CMS content). In order to enable this scenario, we will make the web-applications available as building-blocks within the template or CMS.

Idea

Web applications start within a root-element. We place those root-elements in their desired places on the page (as a content-editor), scan the page (after page-load in the client browser) for elements marked as root-element, and they start their respective applications within.

"Make it so!"

Solution

Creating root elements

To get started, we define that all div-elements with the attribute data-application shall be treated as root-elements. The value of the attribute should provide all information necessary to run the desired application. At a minimum, this includes which application to run (type), a unique ID and options (options).

<div data-application='{"type": "todolist", "id": "maintodolist", "options": {}}'></div>
Example data-application-div

Creating author-placeable widgets

There are different ways to make the elements available to content-authors.

The easiest way is to enable defining custom HTML (as most CMS allow) and provide the content-authors with the code-snippet to paste.

Inserting a custom HTML snippet in ghost cms

It is usually quite easy to make these HTML-snippets available as placeable blocks - all common CMS-systems have a functionality to define custom blocks.

The image gallery shows how to do this for Drupal. For Ghost CMS, just use the "Make snippet" button.

Ghost "Make snippet" button

The same is possible in WordPress (e.g. using the "code-snippets" plugin) and Adobe AEM (as per their official tutorial).

Packaging and scanning

No matter how you build your application (or maybe you don't build it at all), there will be a file that you load with the page. This file will be run on page-load. Typically, you'd include this file via the CMS-theme or together with the HTML snippet. Other options include modifying the raw HTML of the page.

This file can be used to initialise the application

import { todolistapp } from "./todolist";
// import all other apps

if (typeof window !== "undefined") {
  window.addEventListener('DOMContentLoaded', (event) => {
    // trigger all the following functionality from here
  });
}
Entry point script included in page

Scanning the page

Once the script is kicked off, it can be used to scan the page for any data-application-divs. A document-selector searching for the data-attribute will detect all applications on the page. This results in a list of applications on the page.

const appsOnPage = Array.from(document.querySelectorAll("[data-application]")).map(el => {
	const def = JSON.parse(el.dataset.application);
    return ({ el, type: def.type, options: def.options ?? {}, id: def.id ?? uuidv4() });
	// for production you probably want to run this callback in a try-catch
});
Scan the page for applications

Running the applications

Using the list, each application can now be started. This is as straight-forward as it sounds: Loop over the list scanning the page has created and start the application in its element.

const applications = { todo: todolistapp };
appsOnPage.forEach(({ el, type, id, options }) => {
	const App = applications[type];
	ReactDOM.render(<App id={id} key={id} {...options} />, el);
});
Starting applications using react

While this demonstrates the basic functionality, there are a some caveats which are addressed in the following section "Concepts and solution details".

Concepts and solution details

With the above, one react-application is run for each div. It would be a lot better, if there was one react-application running on the page, rendering into each div. This would allow for shared state-management, shared theme, common context and more.

Provider

The goal is to wrap all applications into the same provider; portals enable thus behaviour. Instead of ReactDOM.render, we use ReactDOM.createPortal. Then we wrap the resulting array with the provider.

const applications = { todo: todolistapp };
const RootApplication = () => {
	return <Provider>{appsOnPage.map(({ el, type, id, options }) => {
		const App = applications[type];
		return ReactDOM.createPortal(<App id={id} key={id} {...options} />, el);
	});}</Provider>
}
const div = document.createElement("div");
ReactDOM.render(<RootApplication />, div)
document.body.appendChild(div);

This pattern allows all react apps to share a common state, theme and more: essentially the provider includes all setup the apps have in common.

const Provider = ({ children }) => <StoreProvider store={store}>
    <ThemeProvider settings={settings}>
        <SomeContext>
        	<GlobalStyles />
        	{children}
		</SomeContext>
	</StoreProvider>
</ThemeProvider>;

State management

Sometimes it is desirable or necessary to dynamically add or remove apps. For example, the hosting system might lazy-load parts of the page.

To enable such behaviour, instead of rendering the apps from a global variable, the apps can be rendered from react- (or Redux-) state.

const myexternalstore = new SomeStore({ initialState: appsOnPage })
const RootApplication = () => {
    const [state, setState] = useState(myexternalstore.initialState); // for prodruction, use 'useReducer'
    useEffect(() => {
      const changeApp = ({ action, el, type, id, options }) => {
      	if(action === "add") {
        	setState([...appsOnPage, { el, type, id, options } ])
        }
        if(action === "remove") {
        	setState(state.filter(i => i.id !== id));
        }
        // update, upsert, ...
      };
      myexternalstore.addEventListener("action", changeApp);
      return () => { myexternalstore.removeEventListener("action", changeApp); };
    }, []);
	return <Provider>{state.map(({ el, type, id, options }) => {
		const App = applications[type];
		return ReactDOM.createPortal(<App id={id} key={id} {...options} />, el);
	});}</Provider>
}

A very simple but effective 'SomeStore' could just be an event-target (implementation). An app can be added by calling myexternalstore.dispatch({ action: "add", el: document.querySelector("#mydom"), type: "todoapp", id: "dynamicallyadded", options: {} }).

Implementation

Below is a full implementation that uses all the parts explained above.

// in case the application-type does not exist
const fallback = (props) => (
  <span {...props} data-error="Application not found" />
);

const Provider = ({ children }) => <>{children}</>; // put all your providers here

// all your available apps go here!
const availableApplications = {
  TodoApp: TodoApp
  // ... more application types ...
};

// All applications run from one react root
// This way they share all providers (e.g. store, intl or theme)
const RootApplication = () => {
  const [state, setState] = useState(store.appsOnPage); // for prodruction, use 'useReducer'
  useEffect(() => {
    const changeApp = ({ action, el, apptype: type, id, options }) => {
      console.log(action)
      if (action === "add") {
        setState([...store.appsOnPage, { el, type, id, options }]);
      }
      if (action === "remove") {
        setState(state.filter((i) => i.id !== id));
      }
      // update, upsert, batch-operations, ...
    };
    store.addEventListener("action", changeApp);
    return () => {
      store.removeEventListener("action", changeApp);
    };
  }, []);
  return (
    <Provider>
      {
        // Provider contains store, theme, etc - see above
        state.map(({ el, type, options, id }) => {
          // Translate the applicationtype into the root of the application
          const Application = availableApplications[type] ?? fallback;
          // render the application
          return (
            <div key={id}>
              {ReactDOM.createPortal(
                <Application {...options} key={id} id={id} scope={id} />,
                el
              )}
            </div>
          );
        })
      }
    </Provider>
  );
};
// scan the page for applications
const appsOnPage = Array.from(
  document.querySelectorAll("[data-application]")
).map((el) => {
  const def = JSON.parse(el.dataset.application);
  return {
    el,
    type: def.type,
    options: def.options || {},
    id: def.id // ?? uuidv4() // you might want to create a unique id if none existed 
  };
});
// define the store
const store = new EventTarget({ appsOnPage }); // see link above
// start the react root application
const div = document.createElement("div");
ReactDOM.render(<RootApplication />, div);
document.body.appendChild(div);


// in your js when you want to add/remove an app
store.dispatchEvent({ type: "action", action: hasApp ? "add" : "remove", apptype: "TodoApp", "options":{},"id":"dynamicapp", el: document.querySelector("#dynamicapp")  })

// in the html:
<div data-application='{"type":"TodoApp","options":{},"id":"1234"}'></div>;

Takeaway

Evaluation

While not trivial, with the steps described in this post, it is easy to use React in the context of other systems. This enables seamless development of new applications in their modern stack without needing to give up the existing system.

Extensions

Base64-ed options

To avoid escaping and rendering issues, it makes sense to base64 the options in the data-attribute.

// js
const def = JSON.parse(atob(el.dataset.application));

// html
<div data-application='eyJ0eXBlIjoiVG9kb0FwcCIsIm9wdGlvbnMiOnt9LCJpZCI6IjEyMzQifQ=='></div>

App to app communication

Apps sometimes need to communicate with each other. They can do so, utilising their IDs and a common store. Details are described in a future blog-post.

Embedding CMS content within react

CMS often allow using "content within content". For example, if one of the react-apps is an accordion component, it is required to allow content within. It is easy to achieve this by extracting the DOM from inside the app's HTML before hydrating the react-app.

// in the html:
<div data-application='{"type":"Accordion","options":{},"id":"1234"}'>
	<template data-slot="slot1">
  		{accordion content here}
	</template>
</div>

// js
const fragment = document.createDocumentFragment();
const appsOnPage = [];
const initialize = (doc) => Array.from(
  doc.querySelectorAll("[data-application]")
).forEach((el) => {
  const def = JSON.parse(el.dataset.application);
  const embeddedContent = Object.fromEntries(Array.from(el.querySelectorAll("template")).map(el => {
  	fragment.appendChild(el); // move the child out of the element
    initialize(fragment); // allow app-in-app
  	return [el.dataset.slot, el];
  }));
  el.innerHTML = ""; // clean-up to avoid react warnings
  appsOnPage.push({
    el,
    type: def.type,
    options: { ...def.options, ...embeddedContent }, // slot1 is now a html-element
    id: def.id // ?? uuidv4() // you might want to create a unique id if none existed 
  });
});
initialize(document);


// react component
function Accordion(props) {
  const contentref = useRef(null);
  
  const refreshdom = useCallback(() => {
    if(contentref.current) {
    	contentref.current.appendChild(props.slot1); // move the component
    } else {
    	fragment.appendChild(props.slot1);
    }
  }, []);
  useLayoutEffect(refreshdom); // on each re-render ensure to move the elment into the div

  return (
    <div>
      <div ref={contentref} />
      other stuff
    </div>
  );
}

SSR

Normal SSR does not work with react portals, as React doesn't know about the DOM. However, you can achieve SSR-behaviour by replacing the inner-html of each app with the static markup of their React apps.

const app = {"type":"Accordion","options":{},"id":"1234"};
const root = React.createElement(applicaitons[app.type], {
	...app.options,
    id: app.id,
    key: app.id,
    scope: app.id,
});
// template on the server
`<div data-application='${JSON.stringify(app)}'>
	<template data-slot="slot1">
  		${accordion content here}
	</template>
    ${ReactDOMServer.renderToStaticMarkup(root)}  //  or renderToString? 
</div>`
Apps with SSR

For further information on SSR in portals, see Portals and SSR.

Relation to micro-frontend architecture

Note that instead of a CMS, this can also be used to orchestrate a micro-frontend. Instead of a CMS, the apps are either static or config-driven. The principle is the same, though.

Note, that this differs from the more common approach of exporting individual widgets (e.g. jquery widgets as a library) or components (e.g. react components via module federation).

On a different note, module federation is the other common way (the most common being npm package, optionally in a mono-repo) to export each application.

Svelte and other frameworks

While the above examples are for React, they work equally well for other frameworks - essentially, swap the render and portal calls for their respective ones.

Framework Render Portal
React 17 ReactDOM.render ReactDOM.createPortal
React 18 ReactDOM.createRoot + root.render ReactDOM.createPortal
Svelte Main export, e.g. new App() not build-in, use lib
Angular Use the webcomponent (e.g. <app-root>) e.g. Material UI
Vue new Vue() Teleport

Acknowledgements

Original idea and concept by my good friend Robert.