Sharing Data and Logic Between Microfrontends

Introduction

After four years of breaking down monolithic Angular apps into microfrontends, I’ve noticed one question keeps coming up: “How do we share data between our microfrontends?” It’s a tricky problem that can lead teams down some dangerous paths if not handled carefully. While there’s no one-size-fits-all solution, I’ve developed some strong opinions about what works and what doesn’t. Here’s my take on the different approaches, ranked from most to least preferred.

In future posts I will show some examples of each one.

Ways to share across Microfrontends

Before diving in, let’s establish an important principle: whatever solution we choose needs to be framework agnostic. However you typically build web UIs, you should assume everything will need to play nice with microfrontends built in Angular, React, Vue, vanilla JS, and loads of stuff not even invented yet.

It should be possible to use with another framework, web component, npm package, etc. But here’s the thing. It’s easy. Everything you need to do can be done from Typescript or Javascript. There’s no need to depend on a framework for the vast majority of this kind of stuff.

Alright, let’s get into it. Below are different methods to share data, in order of preference.

1. URL

Whenever it makes sense, you should lean on the URL for state management. It works across microfrontends, and is inherently framework agnostic. Most modern frameworks have powerful routing capabilities - we use Angular Router and you’d be surprised at all the cool things it can do.

2. Utility microfrontend + client package

A utility microfrontend is just a Javascript module (file) that is loaded into your app at runtime (i.e., in your browser). It gets served by a web server (like Nginx) from a container. It is not installed as a package and bundled with the app at build time.

If you have some state that requires special logic, this is a good way to share it. Like any other Javascript, it can be imported into your Angular project and used directly. The module will even be treated as a singleton by default. So each microfrontend will have access to the same instance of this module, along with any internal data.

I see no reason to depend on Angular in your utility microfrontend. It should not contain any UI. Only utilities. So just use Typescript, and compile it to a file.

So what do I mean by “client package”? Sometimes you may need to distribute your utility’s types, or encapsulate logic needed to integrate with it. It makes sense to create an npm package for that.

I still think you should try to avoid depending on a framework in the client package. But if you have to do it somewhere, this is the place. But, don’t be afraid to create multiple packages (scary, I know!). You can separate your client into core, types, and framework specific packages.

3. npm package

This makes sense when the package will not be holding onto any state. For example, a service for interacting with browser storage, or a simple API client with no caching, etc. If your service creates and encapsulates long-lived objects, then ask yourself: “Do I want to create those objects for each microfrontend, or do I want all the microfrontends to use the same object?” If you want to share one object, you should use a utility module. If you truly want an instance for each microfrontend, then an npm package will do.

OK but how should you share it?

Here are some framework agnostic options.

Custom browser events

When you need reactivity, opt for using custom events. They are easy to build, and they work with everything. And if you really want to, you can always build a package that listens to these events and maps them into framework specific constructs, like RxJS Observables.

Private data + public functions

This can be a class, sure. But hear me out. It can also just be a file with some and exported variables and/or functions. A Javascript file is the same thing as a Javascript module (same for Typescript). When it is imported for the first time, the module code is run. From there you can just think of it like a singleton, where the only way to interact with it is via whatever you exported.

The idea here is you should have some private data, and you can only manipulate it through the exported functions. And when the functions do their work, they emit custom events with the new state inside.

Observables

Rxjs might not be a framework, but creating a dependency on it should still be avoided. Custom browser events should be preferred. But, if you decide that observables are the best option, make sure you only depend on Rxjs, and not Angular itself.

Honorable mentions

I have not tried these, but they are worth putting here.

Signals

There are quite a few implementations of Signals out there. A lot of them are only available within a SPA framework. But there are probably some independent ones out there!

Claude (yes the AI) once told me that Angular Signals do not depend on Angular core. So, in theory, they could be used from another framework or vanilla js. But that also implies you could use them with older versions of Angular. That seems farfetched, so it might have just been a hallucination.

At any rate, let’s say you find a library that implements signals. Whether you use it should depend a lot on the extra burden it puts on your bundle sizes. Remember, you are not introducing a dependency to just one app. It’s every microfrontend that uses your shared service.

And it’s worth noting that you probably don’t need it. You can go far with custom events.

State management libraries

I don’t think you should consider libraries in the style of Redux or Ngrx for this purpose. I do like the idea of separating the state store from the method of communication with it. But I think this would wind up making a state store that is accessible to all microfrontends all the time.

I like utility microfrontends over this because they create an API with a clear boundary around some domain of data that can minimize footguns for client microfrontends.

Conclusion

These patterns have served us well across multiple microfrontend migrations. The key is starting simple - lean on the URL when you can, and only reach for more complex solutions when necessary. Whatever approach you choose, keeping your shared code framework-agnostic will save you headaches down the road. And remember, just because you can share something between microfrontends doesn’t always mean you should. Sometimes a little duplication is better than the wrong abstraction.

In future posts I will break down how to build some shared services across multiple microfrontends.