A prototype with React, Vue, Svelte and Web Components

When first reading about micro-frontends some years ago, I thought it was a fun concept, but not very practical. Even if the idea was nice, the suggested implementations seemed klunky. But maybe there could be ways to make it work? This prototype explores some ideas to do this in a feasible manner:

Caveats

This is a prototype, so no nifty abstractions or optimizations.

It uses dynamic import of modules, and that doesn’t work in Edge, yet — but is under implementation. It should mostly work elsewhere.

I haven’t tried to make this mobile friendly, either, since that is not part of the experiment. But there is no reason that this shouldn’t be made to work very well on small screens.

The app is just a silly excuse for creating different routes and views, so there has been very little effort in creating a good user flow.

Background

I won’t say much about micro-frontends here, and I will assume some familarity with the concept. Micro Frontends at martinfowler.com is a nice intro. For me the main thing about micro-frontends is that they are related to micro-services, and:

[Microservice] is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies. https://martinfowler.com/articles/microservices.html

Micro-frontends is a strategy for doing the same on the frontend. Decoupled modules that are arranged around business capabilities or domains. The pros and cons for micro-frontends overlap those for micro-services. But micro-frontends have an additional hard constraint due to the fact at they are consumer facing, and as such, moving to micro-frontends should not impact UX in a negative way.

A lot has been said about the idea of mixing different framworks in the frontend, but that is not a goal in it self for using micro-frontends. Using different technologies is a possibility due to the constraints of hard decoupling of modules within a system, modules that communicates through lightweight protocols independent of whatever application technologies that are choosen. The point is that even if the services are made with the same technology, you should not short-cut the constraints of decoupling and communication between modules. But, of course, there are other reasons for not mixing to many technologies.

Method

The prototype is organized as a set of frontend-modules that are launched and rendered through a home-app that acts as gateway/bootstrap app. Currently the prototype loads modules made with Svelte, React, Vue and a module made with a very small set of web components libraries (lighterhtml, Haunted). The UI is made with the help of wiredjs. The different frameworks are marked with different color backgrounds in the prototype to highlight the different frameworks. There is a color legend in the footer.

This home-app is very lightweight, based on simple skeleton html, some vanilla js, and a small router library (https://vaadin.github.io/vaadin-router/vaadin-router/). The home-app is also responsible for serving global styles and assets.

Each frontend-module follows the same pattern, independent of the framework in question:

The module is bundled as an ES module using Rollup. Here from the Vue-module:

export default {

input: 'src/index.js',

output: {

file: 'public/order.app.js',

format: 'esm'

},

plugins: [

stopDynamicImport (),

replace({

"process.env.NODE_ENV": "'production'"

}),

resolve({

alias: {

'vue$': 'vue/dist/vue.esm.js',

'vuex$': 'vuex/dist/vuex.esm.js'

},

extensions: ['*', '.js', '.vue', '.json']

}),

vue(),

commonjs(),

terser(),

]

}

It exports a common renderer function that acts as a simplified interface to the different frameworks DOM-renderer/bootstrapping functions. For the React-module, it looks like this:

export const ReactApp = (el) => ReactDOM.render(App(), el);

For the Svelte-module, it looks like this:

export const SvelteApp = (el) => new App({ target: el });

The Vue module:

export const VueApp = (el) => {

const App = new Vue({

template: '<OrderPizza/>',

components: { OrderPizza },

store

})

App.$mount(el);

}

The modules are imported into the home app as part of a custom element definition, either by dynamic or static import. Doing it dynamically lets the module be lazy loaded when the custom element is added to the DOM. It is possible to add some intersectionobserver-handling to make the lazy loading even smarter. Here is the React-module loaded dynamially:

class PizzaMenu extends HTMLElement {

connectedCallback() {

const root = document.createElement('section');

root.classList.add('react-module');

this.appendChild(root);

import('./modules/pizzamenu')

.then((module) => {

module.ReactApp(root);

});

}

}

customElements.define('x-pizzamenu', PizzaMenu);

The Header-module (part of Svelte-frontend) is loaded statically:

import { SvelteHeader } from './modules/header';

class HeaderModule extends HTMLElement {

connectedCallback() {

const root = document.createElement('div');

root.classList.add('svelte-module');

this.appendChild(root);

SvelteHeader(root);

}

}

customElements.define('x-header', HeaderModule);

The custom element loads and renders the module on screen when it is added to the DOM.

In the prototype the custom elements loading the modules, except from the header, is added to the DOM by the router. Some of the routes are protected:

const mainOutlet = document.querySelector('main');

const router = new Router(mainOutlet);

let hascustomerid = null;

const paths = {

'/pizzamenu': 'x-pizzamenu',

'/orderpizza': 'x-orderpizza',

'/profile': 'x-profile',

} const guardPath = (context, commands) => {

if (hascustomerid !== null) {

return commands.component(paths[context.path]);

}else{

return commands.redirect('/')

}

}; router.setRoutes([

{ path: '/', component: 'x-login' },

{ path: '/pizzamenu', action: guardPath },

{ path: '/orderpizza', action: guardPath },

{ path: '/profile', action: guardPath }

]); onKernelKey('change:customerid', customerid => {

if (customerid != null) {

hascustomerid = customerid;

Router.go('/pizzamenu');

}

});

The modules communicate and share some state using a Kernel (a concept borrowed from Domain Driven Design), which also is a module that can be loaded dynamically.

“[A shared kernel] Designate some subset of the domain model that the two teams agree to share. Of course this includes, along with this subset of the model, the subset of code or of the database design associated with that part of the model. This explicitly shared stuff has special status, and shouldn’t be changed without consultation with the other team.” (Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, p. 354)

The Kernel is loaded by the different modules, and hooked into the framework specific statemanagement. In the React-module this is done in a useEffect-hook as part of a context provider:

useEffect(() => {

import('./kernel')

.then((module) => {

module.onKernelKey('change:customerid', _customerid => {

setCustomerid(_customerid);

});

const _customerid = module.getSharedKernel('customerid');

if (_customerid != null) {

setCustomerid(_customerid);

}

module.onKernelKey('change:cart', _cart => {

setCart(_cart);

});

const _cart = module.getSharedKernel('cart');

if (_cart != null) {

setCart(module.getSharedKernel('cart'));

}

});

}, []);

Micro-frontends and monorepo

The whole prototype-application is arranged as a monorepo, with sub-repos for home-app and each frontend-module.The prototype is deployed using Zeit Now, which handles both build pipelines and routes. Here is the config:

Result

The resulting prototype is a simple web app, where different routes are rendered using different framework-modules, deployed from individual sub-repos. It shows how the kernel let different modules share a minimal state: Login sets customerid, which other modules use to interact with fake backends. The shoppingcart is also shared, so that changes to it are reflected in the different frontend-modules.

Of course the modules are very lightweight in a prototype like this, but still, it loads and works better than expected. A webpagetest gives mostly A on 4G (https://www.webpagetest.org/result/191030_4N_d0dc1ecdd62ef03a71233ea777164a8d/), and total JS-load is around 136Kb gzipped, wiredjs included.

The prototype demonstrates what I think could become a balanced strategy of modularization, implementation and organization. And it shows a possibly acceptable balance between team autonomity and the constraint of not negatively impacting the user experience. That means the prototype assumes some shared concerns between the micro-frontends, which may seem contrary to more pure implementations of micro-frontends. Those shared concerns are:

Shared ui-components

Wiredjs is really cool, but is also an example of a (mostly) framework agnostic ui-component library, as it is based on web components. Which means that it can be used by the different frameworks independent of each other.

Micro-frontends needs to be aligned on ux and design. If fragmented, it would create a confusing user experience. At least there should be a styleguide, documenting design, styles, ux-patterns etc. But maintaining different implementations of components, would be costly, and even microscopic differences in component behaviour — undetectable for automated tests — can hurt the experience. Also it adds significantly to the combined js load.

Using a framework agnostic ui-component library would help, and this is an area where I think custom elements/web components can make sense. Separating the visual layer from framework specific components for business logic makes sense pattern wise, and reduces technological lock in.

But there is still a need to enforce a common use of design patterns and other macro ux-concerns across micro-frontends. That should be quite enough.

Other shared assets

The prototype serves some shared assets as part of the home-app. That, I think, also makes sense. Not neccessarily all assets, since it can very well make sense to maintain module specific assets within that micro-frontend repo. But serving the same assets from different sources reduces the benefit of caching, and will at some point end up with fragmentation and QA issues.

Monorepo

Monorepo really makes sense for micro-frontends, I think. Maintaining different pipelines increases the complexity without giving much back. Also, it would help for setting up e2e tests and other stuff that it would be counter productive to maintain different setups and versions of. Both shared assets, tests and ui-components could be maintained within the monorepo as sub repos, reducing integration overhead.

Should we do this?

I think this prototype show a reasonable trade-off between UX (user experience), DX (developer experience) and TX (team experience). The shared concerns or dependencies is more or less in the place of what else would be individual dependencies. So it doesn’t hurt TX that much. But not having these trade-offs would hurt UX hard, and probably also DX.

But there is still a question of bundlesizes. Even if the overhead of having both React and Vue was less than expected in the prototype, I expect that a real life version would have bundles with triple the sizes in the prototype. And if you are adding more than one React and/or Vue-module, you could get the possible burden of duplicate run time code. This is as it should be according to some, since sharing run time code — which is totally possible — will introduce coupling between micro-frontends. I think duplicate run time code is unacceptable, and this may be one reason for using monorepo so to let the deployment pipeline synch dependencies in different packages.

But when you start doing at lot of these things, the benefit of micro-frontends dwindles. On the other hand, by using more minimal frameworks, this would certainly be less of a problem. So I think the main result of these experiments is that the prototype demonstrates that with micro-frontends, there could possibly be alternatives to using the dominating frameworks, also for enterprise applications.

Without the overhead of larger runtime evironments, the overhead of loading and executing independent modules based on Svelte, Preact, or similar lightweight libraries/frameworks, would not be that different from doing that within a framework like React or Vue. Maybe even with less total overhead. In that case, the pros could outweigh the cons of using micro-frontends.

Domain teams?

And since the business capabilities would be the same for both micro-services and micro-frontends, or at least aligned, the combination of micro-frontends and micro-services opens up for domain based/oriented teams that take full responsibility for a business domain, from back to front.

Expired: Project Teams Tired: Product Teams Wired: Domain Teams?

So micro-frontends, in combination with very lightweight frameworks, some glue tooling, monorepos, shared ui-library, and an efficient CI pipeline, could be absolutely something worthwhile exploring further.