How We Use React Embedded Apps
This is the first in a three part series about how the PagerDuty Front-end team approaches their micro front-end architecture.
The front-end of PagerDuty’s web app is client-side Javascript, using a combination of React, Ember, and Backbone frameworks. We’re moving towards React, but getting out of legacy code is a slow process, and prioritization needs to be balanced carefully against new features. In the meantime, it is difficult for us to build new features on the older pages and features that affect every page in the site. Embedded React apps solved this nicely for us.
A Solution for Our Global Navigation
One particularly difficult case had been our ten-year old global navigation that existed in multiple versions and on various pages. Bugs and inconsistencies popped up frequently since a content update required a developer to run multiple environments and change the code in multiple Javascript frameworks. Implementing new navigation across multiple frameworks would have taken longer than realistically feasible, and would have limited our ability to create a rich experience.
Before even starting to build the new navigation, we needed to come up with a way that all of our frontend codebases could consume a single React app. Web components were not an option for us due to lack of browser support. Server-side rendering would have been overly complex. Iframes are a bad idea.
We decided to go with a custom embedded React architecture. If you are working on an app with multiple frameworks that would benefit from a lightweight solution for sharing common components, I recommend you take a look at what we did and consider how you might adapt it to your use case.
Our new navigation in the PagerDuty web app. The navigation code exists as its own React repo and is embedded into each page in the site, regardless of framework. For example, the Incidents page shown here is part of an Ember.js monolith, but the navigation is embedded seamlessly into the page from an external React app.
Easy to Embed React Anywhere
A simple script tag is all that’s required to use one of our embedded React apps, which makes them easy to add to any page or framework.
We embed our navigation like this:
We use the script tag raw just like this in the page templates in our Ember and Backbone monoliths, and we made a shared wrapper component for our full-page React apps to use. The script also takes optional height and width attributes to customize the size of the target DOM container.
The Embedded Architecture
There are two parts of the embedded architecture: a loader script and the embedded app itself.
First, the consuming page sends the embedded app ID to the loader script. The loader fetches the app manifest file, which provides it with a list of assets required for that app. Then, it injects script and link tags into the DOM for each of the manifest assets and tells the embedded app which DOM element it should render itself into. Finally, the consuming page loads the app resources and renders the React app.
The embedded app is a regular React app built with Webpack, with the addition of two global functions used for rendering and cleanup.
The Loader Script
This script is loaded into the consuming page with the script tag as described above.
First we create the div
where the app will be mounted. We know the app id from the data-attribute-id attribute passed to the script tag. If width or height were specified, we set those.
Our app assets and its manifest file are stored in AWS and keyed by the app id. The manifest file is created by Webpack at build time and contains a JSON object listing all the assets needed to run the app, including Javascript chunk files and CSS. We use a standard HTTP request to fetch the manifest file by app id. Here’s what ours looks like for the navigation app.
We parse out the Javascript and CSS assets from the manifest, and load each one onto the page by injecting a <script> or <link> tag into the DOM respectively. We set an onload listener so we can track when each asset is loaded. These are all done async. Here’s how we load each Javascript asset.
After the assets are loaded, we are ready to mount the embedded React app. The loader script calls a function named <app-id>_runner that is defined globally within the embedded app. If the function doesn’t exist, we throw an error and the app will fail to load. The user will see an empty placeholder on the page.
The loader script also defines an event listener to do tasks related to unloading. The listener calls a function named <app-id>_destroyer that is defined globally within the embedded app. The destroyer function should unmount the app and do any additional cleanup needed to prevent memory leaks.
At that point, the loader script has done its job and the embedded app takes it from there.
The Embedded App
Any React app can be adapted to work with our loader script by adding a few bits of code. In fact, any app in any framework can be adapted to work with our loader script as long as it creates a Webpack manifest file and defines the global runner and destroyer functions.
For React apps, these functions go in the index.js file
. The runner calls ReactDOM.render
and the destroyer calls ReactDOM.unmountComponentAtNode
.
If you wish to allow multiple embedded apps on one page, you need to add a custom jsonpFunction
prefix to the Webpack config to create a unique code namespace and avoid code collisions.
Note: If you use create-react-app like we do, you can install rescripts to make changes to your Webpack config without ejecting.
The setup on the embedded apps is fairly minimal, but nonetheless we made an embedded app template for our developers so they don’t have to worry about how the plumbing works and can get straight to work building features.
After initial launch, we made our navigation responsive by adding a vertical option for small screens. Updates to the navigation app flow through to every page in the site immediately when the code is deployed, so we can focus our time on building new features rather than how we will deliver the changes.
A Few Minor Downsides
There are a few small downsides to be aware of with this approach. Since the loader script loads first, loads the embedded manifest, and then loads the embedded assets, there is a bit of a lag in the time to render. The performance may or may not be a problem—depending on your use case. For the PagerDuty product in particular, the embedded apps still generally flow within the load time of the rest of our pages, so it’s acceptable as an interim solution.
We also periodically see CSS conflicts occur on pages that use embedded apps because the CSS on either the consuming page or in the embedded app is not sufficiently encapsulated. Modular CSS has helped us solve this problem for the most part, but we still occasionally have cases here and there where we need to resort to an override with !important.
Faster Development and Innovation
We were able to launch a new and modern responsive global navigation on every page in the site from a single React codebase. Developers are happy because it’s so much easier to make updates now, and they don’t have to touch legacy code. Our Product and UX folks are happy because we can make our newest features discoverable from day-one, and the content structure reflects the state of the product today. We were even able to run an A/B test before launching to confirm a positive user experience with our new information architecture. That wouldn’t have been possible with multiple copies of the code. Our navigation is now ready to grow and change right along with our product.
Broad Usage During Legacy Transition
We saw teams adopting the embedded architecture for uses other than the navigation before it was even fully ready for production use. Our product delivery teams are often asked to build new features on legacy pages. They don’t want to work in the legacy code and we don’t want them to create more legacy code, so giving developers the ability to work in React via embedded apps has solved this problem. It has also enabled significantly faster development, stopped the proliferation of tech debt, and gotten all of our product delivery teams working in React. Many of the features that have been released in the past year take advantage of embedded React.
While originally intended as a way to reduce our global navigation code to a single codebase, embedded apps are allowing us to modernize, innovate, and build front-end faster amidst a complicated transition from legacy monoliths to React microservices. Win-win.
To learn more about PagerDuty’s Front-end architecture and infrastructure, stay tuned for the next blog in this series covering how we manage our fleet of micro front-ends