Implementing a Micro-Frontend Architecture With React

Aug 31, 2020 | By J.C. Yamokoski

At FloQast, one of our main goals is to enable high-performance teams. This article will describe one method we are using to increase our speed and efficiency by implementing a micro-frontend architecture using React.

Dismantling the Front-End Monolith

By now the concept of micro-services has become ubiquitous within the software industry; using loosely coupled services in lieu of a single back-end monolith. Some benefits of a service-oriented architecture include:

  • Narrowing the scope of each service, in turn reducing team overhead
  • Creating unique deployment pipelines, in turn reducing lead time (the time it takes to build, test, and deliver code)

While micro-services have helped to dismantle the back-end monolith, we are still left with a large mono-frontend. Not only does this create an overlap of responsibilities between teams, but the shared codebase means a shared deploy pipeline, which feels akin to merging a super-highway into a single lane.

By following the guiding principles of micro-services, and separating the concerns of the mono-frontend into separate clients, aka micro-frontends, we can work to enable the same benefits on the front-end.

Each client exists within its own repository and has its own unique deploy pipeline, thus enabling faster lead times and reducing the amount of overhead for each team. A central hub then composes all clients together at runtime, providing the illusion of a single application. End result: A unified user experience with a decoupled developer experience.

 


Source: Micro Frontends

 

Building Micro-Frontends with React

Each client is a complete React application built using create-react-app. All of which are composed into a single index.html file provided by the hub. The hub also manages the root-level routing to determine which client to load.

A user visits floqast.app which loads the client-hub; then, depending on the URL path, the hub will load the appropriate client.

Some modifications to the bundle produced by webpack are necessary in order to convert the clients from standalone applications to composable ones:

  1. Output a single bundle instead of chunks - This allows the hub to easily fetch the client's bundle.
  2. Output a UMD module - This allows the hub to dynamically load the client's bundle.
  3. Export, don't render - Delegate the rendering from the client to the hub.

We use craco to override the underlying configs without ejecting. By replacing react-scripts with craco, each script first loads any config overrides defined by the cracoConfig property.

The following are the relevant client sources:

// package.json

{
  "name": "project-name",
  "scripts": {
    "start:injected": "REACT_APP_INJECTABLE=true craco start",
    "start:standalone": "REACT_APP_INJECTABLE=false craco start",
  },
  "cracoConfig": "config-overrides/index.js"
}
// config-overrides/index.js

const disableChunks = require('./disable-chunks')
const makeInjectable = require('./make-injectable')

module.exports = {
  webpack: {
    configure: (config, { paths }) => {
      if (process.env.REACT_APP_INJECTABLE === 'true') {
        config = disableChunks(config)
        config = makeInjectable(config, { paths })
      }
      return config
    },
  },
}
// config-overrides/disable-chunks.js

module.exports = function disableChunks(config) {
  // Consolidate bundle instead of chunk
  // https://webpack.js.org/plugins/split-chunks-plugin
  config.optimization.splitChunks = {
    cacheGroups: {
      default: false,
    },
  }

  // Move runtime into bundle instead of separate file
  // https://webpack.js.org/configuration/optimization
  config.optimization.runtimeChunk = false

  // JS
  // https://webpack.js.org/configuration/output
  config.output.filename = 'main.js'

  // CSS
  // https://webpack.js.org/plugins/mini-css-extract-plugin
  const cssPluginIdx = config.plugins
    .map(p => p.constructor.name)
    .indexOf('MiniCssExtractPlugin')
  if (cssPluginIdx !== -1) {
    config.plugins[cssPluginIdx].options.filename = 'main.css'
  }

  return config
}
// config-overrides/make-injectable.js

const { basename } = require('path')

module.exports = function makeInjectable(config, { paths }) {
  // Output a UMD module and define its name via the library key.
  // This key will be what is referenced when the hub looks for
  // the correct module to dynamically load after the bundle is
  // injected into the DOM
  // https://webpack.js.org/configuration/output
  config.output.library = basename(process.env.npm_package_name)
  config.output.libraryTarget = 'umd'

  // Set separate entry point when building the injectable lib
  // https://webpack.js.org/concepts/entry-points
  config.entry = `${paths.appSrc}/index.injectable.js`

  // Exclude shared dependencies to reduce bundle size
  // https://webpack.js.org/configuration/externals
  config.externals = {
    react: 'React',
    'react-router-dom': 'ReactRouterDOM',
    'styled-components': 'StyledComponents',
  }

  return config
}
// index.js

// standalone entry point, renders into #root element
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
// index.injectable.js

// injectable entry point, delegates rendering to the hub
import App from './App'
export default App

The hub is also a React application, and using react-router, creates a new route for each client. The MicroClient component is responsible for dynamically loading the specified client's bundle into the DOM using the react-loadable library.

The following are the relevant hub sources. For the sake of brevity, additional logic such as caching and error handling have been removed.

// App.js

import { Router, Route, Switch } from 'react-router-dom'
import MicroClient from './MicroClient'

const App = () => (
  <Router>
    <Switch>
      <Route
        path="/"
        exact
        render={props => (
          <MicroClient
            {...props}
            clientName="dashboard-client"
          />
        )}
      />
      <Route
        path="/settings"
        exact
        render={props => (
          <MicroClient
            {...props}
            clientName="settings-client"
          />
        )}
      />
    </Switch>
  </Router>
)

export default App
// MicroClient.js

import loadable from 'react-loadable'

const BUNDLE_URL = 'https://where-your-bundles-are-stored'

const loadJS = (url, libName) => {
  const promise = new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.onerror = reject
    script.onload = resolve
    script.async = true
    script.src = url
    document.body.appendChild(script)
  })
  return promise.then(() => global[libName].default)
}

const loadCSS = url =>
  new Promise((resolve, reject) => {
    const link = document.createElement('link')
    link.onerror = reject
    link.onload = resolve
    link.type = 'text/css'
    link.href = url
    document.head.appendChild(link)
  })

const loadBundle = clientName => {
  let promiseChain = Promise.resolve()

  // In dev mode, CRA does not produce a separate CSS bundle
  if (process.env.NODE_ENV !== 'development') {
    promiseChain = promiseChain.then(() =>
      loadCSS(`${BUNDLE_URL}/${clientName}/main.css`)
    )
  }

  promiseChain = promiseChain.then(() =>
    loadJS(`${BUNDLE_URL}/${clientName}/main.js`, clientName)
  )

  return promiseChain
}

const MicroClient = ({ clientName, ...rest }) => {
  const LoadableClient = loadable({
    loader: () => loadBundle(clientName),
    loading: () => <div>Loading...</div>,
  })

  return <LoadableClient {...rest} />
}

export default MicroClient

Potential Obstacles and How We Are Navigating Them

While the decoupled nature of a micro-frontend architecture helps to improve the developer experience, we don't want to lose sight of our end users. The look and behavior of the app should remain consistent regardless of which area of the app the user is visiting. It doesn't matter how fast we can ship code if the end result is not a pleasure to use.

Cross-app Communication

In order to provide a seamless experience across clients, they should be able to share a certain level of UI state. However, they should remain as loosely coupled from one another as possible, lest we begin to circle back into a monolith and lose the advantages we set out to gain. Therefore any communication between them should be handled indirectly and asynchronously. Some examples include:

  • Persisting to shared storage - One client writes to storage enabling others to then read from it. This may be web storage or backend storage depending on the life of the message.
  • Emitting custom events - One client emits an event enabling others to listen and respond accordingly.
An example scenario using custom events

A user visits the admin settings (settings-client), changes the active company, then navigates back to the dashboard (dashboard-client) expecting that the active company persists between page transitions.

In this scenario, when the active company is changed, the settings-client emits an event called “activeCompanyChange” along with the id of the newly selected company:

window.dispatchEvent(
  new CustomEvent('activeCompanyChange', {
    detail: { companyId: nextCompanyId },
  })
)

The dashboard-client then listens for this event and takes action:

window.addEventListener(
  'activeCompanyChange',
  evt => setActiveCompany(evt.detail.companyId)
)

// Be sure to remove any added event listeners to prevent memory leaks.

Shared Styles

The hub not only provides the top level routing, but also the common layout. This ensures each client shares the same surrounding interface. Any modifications to the layout are immediately reflected in each without any specific action required.

We also maintain a UI library from which clients may import common components. To read about the challenges we are facing in regard to managing all the CSS that style our components, please refer to Styled Components in React: Moving away from SCSS.

Shared Utilities

We've also encountered the concern of duplicating common utility functions across the clients. To some extent, we've deemed this as an acceptable approach, mainly as an effort to iterate quickly but also to ensure we avoid implementing the wrong abstraction. However, similar to our UI library, we are working to create a library of common utilities. Flodash, anyone?

Team Fragmentation

Aside from the technical challenges, there are also business concerns as well. One of which is potentially creating fragmentation in how teams work. We are combatting this by encouraging open communication amongst the relevant teams. Along with creating specific Slack channels where we can keep the conversations focused, one particular effective strategy we've employed is to gather representatives from each team and share a collective brain dump of the current state of affairs. We encourage everyone to throw out every question, problem, and concern. Then we work to codify the information into a digestible list. Next, we create a specific action for each item and delegate one to each team. Finally, we reconvene after some time to discuss the results. Repeat as necessary.

Where Do We Go From Here?

So far, implementing a micro-frontend architecture with React has been a success here at FloQast. This is not an endeavor unique to us, and so we will continue to explore, learn, and share while we work to continuously improve our approach.

Some additional resources to consider include:

J.C. Yamokoski
J.C. Yamokoski
J.C. is a Senior Software Engineer at FloQast who loves breaking down large complex problems into small manageable pieces and attempting to re-explain them in a simpler way. When not hacking at code, he enjoys spending time with his family.

Check out research, videos, case studies, and more!

Learn more about working at FloQast!