loading...
Cover image for Microfrontends based on React

Microfrontends based on React

florianrappl profile image Florian Rappl Updated on ・6 min read

In recent years the term "microfrontends" entered the tech mainstream. While there are many patterns of actually implementing microfrontends we feel that there may be an "ideal" solution out there - a solution that combines the advantages of the monolith with some of the benefits of using isolated modules.

In this post we'll look into a microfrontend solution built on React, which allows unbounded scaling of the development, progressive rollouts, and following a serverless infrastructure. Our solution consists of an app shell and independently developed modules, which are dynamically integrated into the app shell.

The solution that we'll use is called Piral, which is a reference implementation of our modular architecture for frontends. The definition of that frontend architecture is based on real-world experiences we gained out of several customer projects over the last three years.

The Modulith

The great thing about an approach that considers the intersection between monolith and micro app (called a Modulith) is that we can allow things such as

  • progressive adoption (for an easy migration path),
  • shared libraries (such as a pattern library), or
  • an existing layout / application frame.

All these are just possibilities. The downside comes with the responsibilities inherited when adopting of such options, e.g., including shared libraries in the app shell will result in the classic dependency management issues.

How does the modulith relate to a microfronted? Below we see a possible microfrontend design - each service gets an associated microfrontend. Every microfrontend represents an isolated unit, potentially coming with its own pattern library and technology.

Microfrontends

In contrast, the Modulith tries to reuse the important parts responsible for UX. As such consistency is key here. Obviously, with this approach some challenges come, too, but the considerations between consistency and redundancy is what makes creating frontend UIs different to backend services.

Modulith

The image above shows the additions of the modulith, which gives a bounding box concerned with the overarching responsibilities. The entry point is the application shell.

An Application Shell

Usually, the creation of a new application that leverages microfrontends starts with the scaffolding of an app shell. The app shell contains the shared layout, some core business functionality (if any), and the share dependencies. The app shell is also responsible for setting up the basic rules that need to be followed by all modules, which are called pilets in the context of Piral.

In the simplest example an app shell could look as follows:

import * as React from "react";
import { render } from "react-dom";
import { Redirect } from "react-router-dom";
import { createPiral, Piral, SetRoute } from "piral";

const piral = createPiral({
  requestPilets() {
    return fetch("https://feed.piral.io/api/v1/pilet/mife-demo")
      .then(res => res.json())
      .then(res => res.items);
  }
});

const app = <Piral instance={piral} />;

render(app, document.querySelector("#app"));

This creates a blank app shell, which already allows having different pages and fragments being stitched together.

Great, so how should we deploy this application? There are two things to do here:

  1. Build (i.e., bundle) the application and push it to some storage.
  2. Package the sources and push it to a (private) registry. Alternatively: Share the tarball.

The first step ensures that our application can be reached from the Internet. Great! The second step requires some explanation. One of the problems when dealing with microfrontends is that "how do I develop this stuff"? After all, we only have a module of a larger application in our hands. What if we want to look into interactions between these modules? What if we want to see if our style fits into the larger UX?

The answer to all these questions can be found in the development of a native mobile app: Here we also did not develop in a vacuum. Instead, we had an emulator - a piece of software that looked and behaved just like the system we will deploy to. In microfrontend terms we require the app shell to be there for our development process. But how do we get this? Especially, since we also want to keep developing while being offline. As a consequence, we need a way of sharing the app shell to allow an "emulation" and thus support a swift development process.

Anatomy of a Pilet

While the app shell is definitely important, even more important are all the pilets. Most of the time a Piral-based app shell is only in maintenance mode - all the features are developed independently in form of the pilets.

A pilet is just an NPM package that contains a JavaScript file ("main bundle", produced as an UMD). Furthermore, it may contain other assets (e.g., CSS files, images, ...), as well as more JavaScript files ("side bundles").

The pilet's content

From a coding perspective a pilet has only one constraint - that it exports a function called setup. This function receives the API that allows the developer of the pilet to decide which technologies and functions to utilize within the module.

In short, a pilet may be as simple as:

import * as React from "react";
import { PiletApi } from "app-shell";

export function setup(app: PiletApi) {
  app.registerPage("/sample", () => (
    <div>
      <h1>Hello World!</h1>
      <p>Welcome to your personal pilet :-).</p>
    </div>
  ));
}

Naturally, pilets should be as lazy as possible. Thus, any larger (or even part that may not required immediately) should only be loaded when needed.

A simple transformation with methods from our standard tool belt can help:

// index.tsx
import * as React from "react";
import { PiletApi } from "app-shell";

const Page = React.lazy(() => import("./Page"));

export function setup(app: PiletApi) {
  app.registerPage("/sample", Page);
}

// Page.tsx
import * as React from "react";

export default () => (
  <div>
    <h1>Hello World!</h1>
    <p>Welcome to your personal pilet :-).</p>
  </div>
);

All that works just fine with Piral. It's important to keep in mind that in the (granted, quite simple) codebase above, Piral is only mentioned in the root module. This is a good and desired design. As the author of a pilet one may pick how deep Piral should be integrated. Our recommendation is to only use the root module for this integration.

So far so good, but how is the pilet then brought into our (real, i.e., deployed) app shell? The answer is the feed service. We've already seen that our app shell fetched some data from "https://feed.piral.io/api/v1/pilet/mife-demo". The response to this request contains some metadata that allows Piral to retrieve the different pilets by receiving a link to their main bundle.

Everyone is free to develop or roll out a custom-made feed service. By providing the specification and an Express-based Node.js sample we think the foundation is there. Additionally, we host a flexible feed service online. This one includes everything to get started efficiently.

The Piral CLI

All the magic that happened so far can be found within the Piral CLI. The Piral CLI is a simple command line tool that takes care of:

  • scaffolding (with piral new for a new app shell or pilet new for a new pilet)
  • debugging (with piral debug to debug an app shell; for pilets use pilet debug)
  • building (with piral build or pilet build)
  • publishing a pilet (pilet publish)

In the whole high-level architecture the place of the Piral CLI is right between the developer and the feed service. As already remarked, the feed service is the only required backend component in this architecture. It decouples the application shell from the specific modules and allows more advanced use cases such as user-specific delivery of modules.

Responsibilities of the Piral CLI

Internally, the Piral CLI uses Parcel. As a result, all plugins for Parcel (as well as their configuration - if required) just work.

The Piral CLI also supports plugins on its own.

Further Reading

There are already some articles out about Piral.

Furthermore, the documentation may also be helpful. It contains insights on all the types, a tutorial storyline, and a list of the available extensions.

Get Piral!

If you are thinking about adopting microfrontends, Piral might be the choice for you. It requires the least infrastructure giving you the most value for your users. Piral was designed to provide a first-class development experience, including the possibility of a progressive adoption (i.e., starting with an existing application - bringing in the capability of loading pilets before actually developing pilets).

With the optional inclusion of "converters" (e.g., Angular, Vue) it is possible to support multi-technologies or migrations of legacy technology. The current list of all official extensions (incl. converters) is accessible on our docs page.

We would love to get your feedback! 🍻

Share the link, star the project ⭐ - much appreciated ❤️!

Discussion

pic
Editor guide
Collapse
cubiclebuddha profile image
Cubicle Buddha

Since a premise of microservices is independent deployment, how does Piral enforce independent deployments if it allows the use of shared libraries?

For instance, if Pilet A and Pilet B want to use the same shared library but Pilet A wants a newer version... is Pilet B going to get the version it wants or the version that Pilet A requested?

Collapse
florianrappl profile image
Florian Rappl Author

Thanks for the question - it's a good and reasonable one.

For shared libraries there are two possibilities:

  1. Use the one provided from the application shell. This gives the best performance, is most convenient and is done already with core dependencies such as React. However, this puts the burden of correct dependency management on the app shell. As a consequence updating may be difficult (tooling helps here).

  2. If pilets want more freedom, but still the gain of flexibility the other choice is sharing from the pilets by using a script import and a shared URL. Long story short - if two pilets use a script import from the same URL, the URL will only be used once. So if one pilet goes for a different version we'll have no problem.

If a pilet really wants to be independent dependencies can always be bundled in (shouldn't be done for the already provided core dependencies, but for "new" dependencies it makes sense). Our recommendation is to use this, and once recognized as being used by multiple pilets a central team can always think about aligning such a dependency. "Depends" as usual, e.g., on performance (size) and other (e.g., consistency, coupling, ...) factors.

Hope that helps!

Collapse
cubiclebuddha profile image
Cubicle Buddha

Thanks. Have you or your team looked into the import map spec as a way to make this dependency management easier?

I ask because your competitor (single-spa), has made a lot of advancements with using import maps to enable teams to control their dependencies. It’s fascinating stuff, but I must admit that it’s a bit above my head! Haha.

Thread Thread
florianrappl profile image
Florian Rappl Author

I think import maps (this one, wicg.github.io/import-maps/, right?) is a promising approach. Right now we rely on import() (yielding a promise), but maybe we can also support it in the future as an alternative.

Thanks for the hint!

Collapse
rohansawant profile image
Rohan Sawant

Wasn't this exactly what react was meant to be used like?

Excellent read! 🔥

Collapse
mati365 profile image
Mateusz Bagiński

Microfrontends architecture is the stupidest idea nowadays. I think it was invented by API developers who really know nothing about frontend optimizations. In real world most of the modules will be not independent - over the time number of dependencies between modules will increase (with internal deps such as NPM packages). That architecture leads to creating sluggish and huge unmaintable apps. Besides it - what about SSR and page loading time optimization?

Collapse
florianrappl profile image
Florian Rappl Author

Its a valid opinion and the architecture is certainly not for everyone. Besides if you would have read the article (and presumably not stop the the title) you would have seen that we share all the major building blocks (for exactly the reason as you outlined).

For the clients where we rolled out the solution everyone seems to be super happy about it. I've also never seen two modules that collide with each other.

SSR is possible due to React, but page loading time can (as usual) be an issue. Here it really depends what you are after; if you create a tool (this is the target audience) the initial loading time is not as crucial as for creating a fast content page.

It's not all black and white.

Collapse
jstuckey_51 profile image
James Stuckey

Maybe I haven't had a need for this yet, but would you mind explaining the benefit of this approach?

Collapse
florianrappl profile image
Florian Rappl Author

Of what approach? Microfrontends? Or a particular pattern?

dev.to/florianrappl/5-reasons-for-...

Collapse
jstuckey_51 profile image
James Stuckey

Micro-frontends using a React App. I guess I'm just not able to see how it helps individual teams working on different features. I'll give your article a read and see if I'm still confused.

Thread Thread
florianrappl profile image
Florian Rappl Author

Well, for a single team working on multiple features it does not necessarily help. Why should it (note: this is not a silver bullet)?

It helps, e.g., when

  • there are multiple teams implementing multiple features
  • the individual features need to be integrated
  • multiple frameworks need to be included

... and many more cases (see the reasons / article I've linked).

Hope that helps!