React micro-frontend in ember app - Part 1
Tags: javascript, react, ember
Published: 2023-08-15 04:56:39

In this post, we are going to look at how to integrate a [React](https://react.dev/) application with its routing mechanisms as a micro-frontend in an [Ember](https://emberjs.com/) application. If you are here then I hope you are already aware of these frameworks so let's look at why we want to integrate React app into an Ember app first.

## Why?

The world is moving towards the micro-frontend(MFE) architecture. Ember is best suited for large-scale applications, it has everything you need to create a robust enterprise application, whereas React is a lightweight library with tons of open-source plugins which makes it highly customizable and perfectly fit our MFE need.

Yes, I hear you. There are other frameworks like [SolidJs](https://www.solidjs.com/), [SvelteJs](https://svelte.dev/) which are gaining popularity and can be a tough competition to React. These frameworks don't ship any extra framework-related code in your bundle and do most of the heavy lifting at the compile time instead of the browser which makes it extremely lightweight even than React itself. But we all know, React is an undisputed leader in the JavaScript Framework space and the sheer amount of developers who know and already worked on React is mind-boggling. So we picked 'React' to be the framework of choice for the MFE.

## Prerequisites

### Tools

You will need the following things properly installed on your computer.

- [Git](http://git-scm.com/downloads)
- [Node.js](http://nodejs.org/)

### PNPM install

- If you have installed the latest v16.x or greater node version in your system, then enable the PNPM using the below cmd

```bash
corepack enable
corepack prepare pnpm@latest --activate
```

- If you are using a lower version of a node in your local system then check this page for additional installation methods https://pnpm.io/installation

### Ember CLI install

- Ember CLI is the command line tool provided by Ember to create the Ember app. So let's install it globally.

```bash
 npm install -g ember-cli
 ember -v
```

## Setting up the Repo

We are going to set up a [mono repo](https://monorepo.tools/) using [PNPM workspaces](https://pnpm.io/workspaces) for this POC. If you are trying to add this MFE to an existing app, I highly recommend keeping your apps in a separate Repo. We surely don't want any outdated tools and setup to become a blocker from using the latest technologies for our new MFE.

This is how I set up the mono repo structure using the command line.

```bash
mkdir ember-react-mfe
cd ember-react-mfe
pnpm init
git init
echo -e "node_modules" > .gitignore
echo "Monorepo with react MFE in ember app" > README.md
```

### Workspace config

- Create a `pnpm-workspace.yaml` file and add the following content

```bash
touch pnpm-workspace.yaml
```

```yaml
packages:
  - 'apps/*'
```

- Create the `apps` directories in the root.

```bash
mkdir apps
```

Our basic mono repo setup is now done. Let's create our React app first.

## React app

- We are going to use [Vite](https://vitejs.dev/) bundler to create our React app.

```bash
cd apps
pnpm create vite admin-app --template react-ts
cd ../
pnpm install
npm pkg set scripts.admin="pnpm --filter admin-app"
```

- To start the react admin app in the dev server run `pnpm admin dev`.

- Let's add the routing capability to our react app by installing the [react-router-dom](https://reactrouter.com/en/main) npm package.

```bash
pnpm admin add react-router-dom
```

### Sample pages

- Create the router file with all the necessary routings.
  For simplicity purposes, I have added all the components in this same file instead of creating separate files.

In an ideal world, it should be lazy-loaded to support code splitting from separate folders and files. Whatever you do in a normal React application can be done without any issues.

```bash
touch apps/admin-app/src/router.tsx
```

Copy the content from `router.tsx` file from this gist.
https://gist.github.com/vinomanick/919ad40ab98716695837c207526ee156

- Update the `main.tsx` file, to support the routing instead of rendering the default App component.

```js
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { RouterProvider } from "react-router-dom";
import { generateRouter } from "./router.tsx";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={generateRouter({ basename: "/" })} />
  </React.StrictMode>
);
```

- Run the server and make sure all the routes are working as expected.

- Let's remove the App component-related files and update the index.css as follows.

```bash
rm -rf apps/admin-app/src/App.tsx apps/admin-app/src/App.css
```

- Copy the content of index.css from `index.css` file from this gist.
  https://gist.github.com/vinomanick/919ad40ab98716695837c207526ee156

### Convert the react app to a web component

- The react app can be directly rendered in the ember app but it is best to wrap the entire application in a web component to have strong encapsulation of styles between the host and the MFE.

- The `main.tsx` file is responsible for mounting the react app to the element with id as `root` on load. We need to change that logic and mount the react app inside an element present in the web component shadow DOM and also load the styles inside the web component as shown below.

```js
import React from 'react'
import ReactDOM from 'react-dom/client'
import indexStyle from './index.css?inline'
import { RouterProvider } from 'react-router-dom'
import { generateRouter } from './router.tsx'

class AdminMFE extends HTMLElement {
  constructor() {
    super()
  }

  connectedCallback() {
    console.debug('Admin MFE element added to app')
    const mountPoint = this.createMountPoint()
    const props: Record<string, any> = this.getProps(this.attributes)
    this.addStyles()
    this.mountReactApp(mountPoint, props.options)
  }

  /**
   *  Creates a mount point and attach it to the shadow dom
   */
  createMountPoint() {
    const mountPoint = document.createElement('div')
    mountPoint.setAttribute('id', 'admin-mfe')
    mountPoint.style.height = '100%'
    mountPoint.style.overflow = 'auto'
    this.attachShadow({ mode: 'open' }).appendChild(mountPoint)
    return mountPoint
  }

  /**
   *  Mounts the react app in to the given element
   */
  mountReactApp(element: HTMLElement, { basename = '' }: { basename: string }) {
    ReactDOM.createRoot(element).render(
      <React.StrictMode>
        <RouterProvider router={generateRouter({ basename })} />
      </React.StrictMode>
    )
  }

  /**
   * Adds the style to the web component as an inline tag
   */
  addStyles() {
    const id = 'admin-style'
    const style = document.createElement('style')
    style.id = id
    style.appendChild(document.createTextNode(indexStyle))
    this.shadowRoot?.appendChild(style)
  }

  getProps(attributes: NamedNodeMap) {
    return [...attributes]
      .filter((attr) => attr.name !== 'style')
      .map((attr) => this.convert(attr.name, attr.value))
      .reduce((props, prop) => ({ ...props, [prop.name]: prop.value }), {})
  }

  convert(attrName: string, attrValue: any) {
    let value: any = attrValue
    if (attrValue === 'true' || attrValue === 'false') {
      value = attrValue === 'true'
    } else if (!isNaN(attrValue) && attrValue !== '') {
      value = +attrValue
    } else if (/^{.*}/.exec(attrValue)) {
      value = JSON.parse(attrValue)
    }
    return { name: attrName, value: value }
  }
}

customElements.define('admin-mfe', AdminMFE)
```

### Local dev server update

- Update the index.html by removing the `root` div and adding the web component. The body should be something like this.

```html
<body>
  <script type="module" src="/src/main.tsx"></script>
  <admin-mfe />
</body>
```

- Run the dev server and inspect the DOM, you should be able to see the whole app rendered inside and the web component.

### Exposing the web component as a loadable script

For Vite bundler, the entry file is the index.html so leaving that all the other compiled assets are automatically fingerprinted. So we won't be able to simply call the bundled `index-<hash>.js` from the host app. To solve that we need to do the followings,

- Update the Vite configuration to produce a manifest file with all the fingerprinted assets and its location.

```js
export default defineConfig({
  plugins: [react()],
  build: { manifest: true },
})
```

- Create a new entry point to our app in the public folder. Anything added in the public folder will be copied to the dist folder by Vite.

```bash
touch apps/admin-app/public/admin-mfe.js
```

- Update it with the following content,

```js
export const mountApp = ({ element, options }) => {
  const { baseURL, ...rest } = options;
  if (!baseURL) {
    throw Error(
      'Please provide the baseURL in the options for the admin MFE to load'
    );
  }
  fetch(`${baseURL}manifest.json`)
    .then((res) => res.json())
    .then((data) => {
      import(`${baseURL}${data['index.html'].file}`).then(() => {
        const adminMfe = document.createElement('admin-mfe');
        adminMfe.setAttribute('options', JSON.stringify(rest));
        element.appendChild(adminMfe);
      });
    });
};
```

This will expose a method called mountApp which can be used by the host app to attach the web component to the host app element.

## Building the react app and making it available for the ember app

- Build the react app in watch mode

```bash
pnpm admin build --watch
```

- Open a new terminal, run the preview command

```bash
pnpm admin preview
```

The preview command will run a static server at `http://localhost:4173` that points to the react app dist folder.

so the host app has to request `http://localhost:4173/fw-chat-admin.js` which will act as an entry point for our MFE.

Congrats if you made it here, with this we are done with the setup for react application. Let's see how to link and load this app inside the ember app in the next part.