In this article, I'm going to explain some ways to communicate between multiple applications and a particular way that I have chosen to use in my current project and work.
If you are not familiar with the micro frontends
concept and architectures I suggest you take a look at these amazing articles:
- https://microfrontends.com
- https://micro-frontends.org
- https://martinfowler.com/articles/micro-frontends.html
There are several reasons for choosing a micro frontend architecture, maybe your app has grown too much, or new teams are coding on the same repo/codebase, but one of the most common use cases is the decoupled logic of certain domain of an App.
Following this logic, good architecture is one in which micro frontends are decoupled and do not need to frequently communicate but there are some things that micro frontends might share or communicate like functions, components, some logic, or state.
Sharing code
For functions, components and common logics could be placed on a third package and imported on each app.
And for creating a package there are several approaches I won't dive deep into it, but I'll leave you some examples:
- Creating a simple typescript library
- Creating a react component library
- Creating a component library with Lerna
- Using a Zero-config CLI for TypeScript package development (TSDX)
Sharing state
But what about a shared state? Why would someone need to share state between multiple apps?
Let's use a real-world example, imagine this e-commerce:
Each square represents a micro frontend with a specific domain or functionality and could be using any framework.
Adding some content we notice some parts of the app that might need to share some data or state like:
- Both item detail and suggested items might need to communicate and inform the cart when an item has been added
- The suggested items could use the current items in the cart to suggest another item based on some complex algorithms
- Item detail could show a message when the current item is already on the cart
If two micro frontends are frequently passing state between each other, consider merging them. The disadvantages of micro frontends are enhanced when your micro frontends are not isolated modules. this quote from single-spa docs it's awesome, maybe the suggested items could be merged with item detail but what if they need to be indifferent apps?
Well for those use cases I have tried 5 different modes:
- Web Workers
- Props and callbacks
- Custom Events
- Pub Sub library(windowed-observable)
- Custom implementation
Comparison table
- ✅ 1st-class, built-in, and simple
- 💛 Good but could be better
- 🔶 Tricky and easy to mess up
- 🛑 Complex and difficult
Criteria | Web workers | Props and callbacks | Custom Events | windowed-observable | Custom implementation |
---|---|---|---|---|---|
Setup | 🛑 | ✅ | ✅ | ✅ | 🔶 |
Api | 🔶 | 💛 | 💛 | ✅ | 🔶 |
Framework Agnostic | ✅ | ✅ | ✅ | ✅ | 🔶 |
Customizable | ✅ | ✅ | ✅ | ✅ | 🔶 |
Web Workers
I have created an example to illustrate a simple communication between two micro frontends with a dummy web worker using workerize-loader
and create-micro-react-app
also known as crma
to setup the react micro frontends.
This example is a monorepo
with 2 micro frontends, 1 container app, and a shared library exposing the worker.
Worker 📦
let said = [];
export function say(message) {
console.log({ message, said });
said.push(message)
// This postMessage communicates with everyone listening to this worker
postMessage(message);
}
Container app
The container app is sharing the custom worky
web worker.
...
import worky from 'worky';
window.worky = worky;
...
You should be thinking 🤔
But why don't you import this
worky
on every micro frontend?
When importing a library from the node_modules and using it in different apps every worker.js
will have a different hash after bundled.
So each app will have a different worker since they're not the same, I'm sharing the same instance using the window but there are different approaches.
Microfrontend 1️⃣
const { worky } = window;
function App() {
const [messages, setMessages] = useState([]);
const handleNewMessage = (message) => {
if (message.data.type) {
return;
}
setMessages((currentMessages) => currentMessages.concat(message.data));
};
useEffect(() => {
worky.addEventListener('message', handleNewMessage);
return () => {
worky.removeEventListener('message', handleNewMessage)
}
}, [handleNewMessage]);
return (
<div className="MF">
<h3>Microfrontend 1️⃣</h3>
<p>New messages will be displayed below 👇</p>
<div className="MF__messages">
{messages.map((something, i) => <p key={something + i}>{something}</p>)}
</div>
</div>
);
}
Microfrontend 2️⃣
const { worky } = window;
function App() {
const handleSubmit = (e) => {
e.preventDefault();
const { target: form } = e;
const input = form?.elements?.something;
worky.say(input.value);
form.reset();
}
return (
<div className="MF">
<h3>Microfrontend 2️⃣</h3>
<p>⌨️ Use this form to communicate with the other microfrontend</p>
<form onSubmit={handleSubmit}>
<input type="text" name="something" placeholder="Type something in here"/>
<button type="submit">Communicate!</button>
</form>
</div>
);
}
Pros ✅
- According to MDN The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.
Cons ❌
- Complex setup
- Verbose API
- Difficult to share the same worker between multiple micro frontends without using a window
Props and callbacks
When using react components you could always lift the state using props and callbacks, and this is an awesome approach to share small interactions between micro frontends.
I have created an example to illustrate a simple communication between two micro frontends using crma
to set up the react micro frontends.
This example is a monorepo
with 2 micro frontends and one container app.
Container app
I have lifted up the state to the container app and passed messages
as a prop and handleNewMessage
as a callback.
const App = ({ microfrontends }) => {
const [messages, setMessages] = useState([]);
const handleNewMessage = (message) => {
setMessages((currentMessages) => currentMessages.concat(message));
};
return (
<main className="App">
<div className="App__header">
<h1>⚔️ Cross microfrontend communication 📦</h1>
<p>Workerized example</p>
</div>
<div className="App__content">
<div className="App__content-container">
{
Object.keys(microfrontends).map(microfrontend => (
<Microfrontend
key={microfrontend}
microfrontend={microfrontends[microfrontend]}
customProps={{
messages,
onNewMessage: handleNewMessage,
}}
/>
))
}
</div>
</div>
</main>
);
}
Microfrontend 1️⃣
function App({ messages = [] }) {
return (
<div className="MF">
<h3>Microfrontend 1️⃣</h3>
<p>New messages will be displayed below 👇</p>
<div className="MF__messages">
{messages.map((something, i) => <p key={something + i}>{something}</p>)}
</div>
</div>
);
}
Microfrontend 2️⃣
function App({ onNewMessage }) {
const handleSubmit = (e) => {
e.preventDefault();
const { target: form } = e;
const input = form?.elements?.something;
onNewMessage(input.value);
form.reset();
}
...
}
Pros ✅
- Simple api
- Simple setup
- Customizable
Cons ❌
- Difficult to set up when there are multiple frameworks(Vue, angular, react, svelte)
- Whenever a property changes the whole micro frontend will be rerendered
Custom Events
Using Synthetic events is one of the most common ways to communicate using eventListeners
and CustomEvent
.
I have created an example to illustrate a simple communication between two micro frontends, this example is a monorepo
with 2 micro frontends and 1 container app using crma
to set up the react micro frontends.
Microfrontend 1️⃣
function App() {
const [messages, setMessages] = useState([]);
const handleNewMessage = (event) => {
setMessages((currentMessages) => currentMessages.concat(event.detail));
};
useEffect(() => {
window.addEventListener('message', handleNewMessage);
return () => {
window.removeEventListener('message', handleNewMessage)
}
}, [handleNewMessage]);
...
}
Microfrontend 2️⃣
function App({ onNewMessage }) {
const handleSubmit = (e) => {
e.preventDefault();
const { target: form } = e;
const input = form?.elements?.something;
const customEvent = new CustomEvent('message', { detail: input.value });
window.dispatchEvent(customEvent)
form.reset();
}
...
}
Pros ✅
- Simple Setup
- Customizable
- Framework agnostic
- Micro frontends don’t need to know their parents
Cons ❌
- Verbose custom events api
Windowed observable
In this new era of "micro" services, apps, and frontends there is one thing in common, distributed systems.
And looking at the microservices environment a pretty much popular communication mode is pub/subs queues just like the AWS SQS and SNS services.
Since every micro frontend and the container are at the window
, I decided using the window
to hold a global communication using a pub/sub implementation, so I created this library mixing two concerns pub/sub-queues and Observables, called windowed-observable
.
Exposing an Observable attached to a topic to publish, retrieve, and listen to new events on its topic.
Common usage
import { Observable } from 'windowed-observable';
// Define a specific context namespace
const observable = new Observable('cart-items');
const observer = (item) => console.log(item);
// Add an observer subscribing to new events on this observable
observable.subscribe(observer)
// Unsubscribing
observable.unsubscribe(observer);
...
// On the publisher part of the app
const observable = new Observable('cart-items');
observable.publish({ id: 1234, name: 'Mouse Gamer XyZ', quantity: 1 });
In this library there are more features like retrieving the latest event published, getting a list with every event, clearing every event, and more!
Using windowed-observable
on the same app example:
Microfrontend 1️⃣
import { Observable } from 'windowed-observable';
const observable = new Observable('messages');
function App() {
const [messages, setMessages] = useState([]);
const handleNewMessage = (newMessage) => {
setMessages((currentMessages) => currentMessages.concat(newMessage));
};
useEffect(() => {
observable.subscribe(handleNewMessage);
return () => {
observable.unsubscribe(handleNewMessage)
}
}, [handleNewMessage]);
...
}
Microfrontend 2️⃣
import { Observable } from 'windowed-observable';
const observable = new Observable('messages');
function App() {
const handleSubmit = (e) => {
e.preventDefault();
const { target: form } = e;
const input = form?.elements?.something;
observable.publish(input.value);
form.reset();
}
...
}
Feel free to take a look and also use it ❤️
Pros ✅
- Simple api
- Simple setup
- Pretty much customizable
- Namespace events isolation
- Extra features to retrieve dispatched events
- Open source ❤️
Cons ❌
Custom implementation
After all of these examples you could also merge some of them and create your custom implementation, using your abstractions encapsulating your app needs, but these options could be tricky and easy to mess up.
Conclusion
There is no perfect or best solution, my suggestion is to avoid hasty abstractions and tries to use the simplest solution like props and callbacks if it does not suit to your needs try the other one until it feels good!
You can dive deep in those examples in this repository.
Comment below which one you prefer and why 🚀
Top comments (25)
Really nice article - well done!
There is, however, one problem with events: You only get notified in case of changes. Especially for your problem statement (sharing some global state) you'd be interested in the current snapshot when starting. So this assumes that all interested parties are already there when the state modifier starts submitting events. This, obviously, is in general not the case. Here, you'd end up with strange situations.
A more reliable solution would be to come up with events in both directions; i.e., one to get informed about the current snapshot (originating from interested microfrontends) and one to inform about changes to the snapshot (originating from the modifier).
One problem I see with the
windowed-observable
lib is that it hides what it changes onwindow
. Sure, now every microfrontend can embed this and presumably it still works (if more or less compatible versions are used) but what if somebody tries to use__shared__
for other purposes.Thanks again!
I was gonna say this but you arrived first, good point 😉 Well, you can always come up with an unique name and document it in your overall app architecture so that no one messes up with the shared thingy. Maybe this could even be configurable in your build process as an environment variable with the build number or what not. About having a snapshot of what has happened when your micro rendered is a must, so others can track/react to already triggered events.
This was really a nice article to read. Looking forward to more of your content.
Unfortunately it's not so easy. The name has to be known by the microfrontends which are build and deployed independently.
Hum... to solve it those keys/namespaces could be wrapped in another package which exposes only the communication API, and every micro-frontend uses this API
@vbarzana This kind of comment made my day, I'll try to create more content for sure!
I'm glad you liked it ❤️
Yep that's the main problem using the window someone may interfere in those keys 😥
@florianrappl it's a fair point, but at a certain point don't you have to simply trust the developers and hope that you hired the right people? Also, I think documentation, training, and on-boarding at the company should help tell people that you only communicate between microfrontends through the use of the recommended library (i.e. don't touch/read directly from window, use the library instead).
Nice work @luistak
Fala Luiz.
Tudo bem?
Cara primeiro dizer que gostei muito do seu post. Muito bom mesmo...
Queria tirar umas dúvidas com você a respeito da funcionalidade de "Retrieving latest event". Estou com uma necessidade aqui no trabalho de utilizar a comunicação entre MF's e pelo que li na documentação e vi em seu video a sua lib se encaixaria muito bem, porém, acredito que não estou sabendo utilizar muito bem essa funcionalidade de obter o último evento.
Poderia trocar uma ideia?
Valeu
This was one of the few articles worth reading in at least month. Thanks for the work you put into this. It's something I've banged my head for the past year and you just shined light on the darkest part of using this pattern. Thank you
This kind of comment really cheered me and pushed me to write more! I'm really glad you liked it, I held this article for a long time thinking it wasn't good enough.
I hope it helped at least a little in this matter o /
really nice, thank you for your share!
I like the window observable approach you've recommended, but I think it's important to list that it has a "con" to the approach: It misses type safety.
Sure, you can change the type in the interface from
T extends any
toT extends unknown
and that will help a little bit. But that doesn't solve the problem that occurs when a MicroFrontend falls out of date. To phrase that as a question:What does the observing MicroFrontend (let's call her "O") do when the publisher (let's call her MicroFrontend "P") changes the structure of the event that it's publishing?
For instance:
What happens?
Well, MicroFrontend O is going to have a runtime error since there was no requirement to re-compile/re-deploy MicroFront O after MicroFrontend P changed it's code.
Note: Consider subscribing to me, as I'll be writing my own article on this specific problem, and I'll likely refer to yours as a starting block. Thank you for your hard work @luistak.
Over that problem, I would suggest creating a new abstraction that will always handle both sides and exposes a consistent API that may not change
This is the same problem as
Rest api's
I've seen some folks solving this problem versioning their endpoints, so another solution would be versioning those namespaces to something like:
And each version would have different responses
I'm going to start a migration project next month and I'm gonna use micro-frontends to approach this project, one of the things I was considering for state management is the webworker solution here! One change I would make is that I'd have a redux store on the webworker and standardize redux over both apps, great article!
Hi José o/
Good luck with your project!
I don't really recommend using redux as a global state in a worker, consider sharing only the essential information
This quote from single-spa is awesome and also try to avoid "over-reduxing"
don't see the need for any of this which will add another platform dependent part into my application. Most react applications already use either mobx or redux, they are platform agnostic. I'm sure what ever library you use on Angular or Vue should expose observable or subscription api too.
Hi River!
The main message I was willing to pass was those types of sharing information between multiple micro-frontends, for sure your app may not need to do it what is also great!
Is there any better way to securely communicate a short data between two separate Angular Applications that don't run in the browser at the same time.
For example, send some JSON data from example.com/firstAngularapp to example.com/OtherAngularApp ?
For sure if they're at the same domain you can use localStorage or any relevant web storage/db to sync that data
Great article!
Thank you! I hope you liked it
I have a use case in which i have to communicate from iframe to parent and vice-versa
i know how to solve it but i am unable to change the existing code base of windowed-observable
We have to replace the window object with window.top and build it.
If there is any issue with this approach we can discuss