Microfrontends in JS: Using message buses
More often than not, the task of developing a microfrontend is actually converting an existing monolothic react application to a modularised variant of itself, allowing parts of it to be independently consumed.
The problem here is that we cannot expect consumers to rely on the same framework choices that we make, limiting our options to using only what Javascript provides out-of-the-box.
This article proposes one such solution using message buses, also referred to as an Event driven solution to passing data around various Microfrontend components.
(Skip to the example section if you wish to directly understand the architecture)
Tenets
The task here is to develop a context, a shared “language” for the components to talk to each other.
BUT — there is a catch, we want to optimise this as far as possible and reduce the number of re-renders happening at the container level (We refer to a container being the component that houses all our microfrontends).
Thus, using the container as a centralised datastore (a rather abused architectural pattern) is out of the picture. Any dispatches/mutations taking place in any of the child components would atleast trigger a re-render in every child (Note — A re-render is not the same as a re-paint, atleast in React based applications, and while the former has a lower cost than the latter, it is still an unnecessary cost to pay)
We want this communication pattern to be extensible, so if one component decides to output more “shared” data, it should be possible to do with the lowest blast radius as possible.
What this means is that we’d want to make the least amount of code changes in irrelevant components as far as possible. As a result, any prop based solution where the container holds state (be it in any form — context/useState/etc) is out of the picture, since any changes to a child component would lead to the developer having to add additional callbacks/alter the state definition at a global level/add additonal props to other components. The latter is especially deterimental as this list of props can keep growing.
Solution
CustomEvent to the rescue!
JavaScript Execution Flow and CustomEvent
Skip to the example section if you wish to directly understand the architecture
- Creating a CustomEvent
We define a custom event like so -
new CustomEvent(‘eventName’, { detail: customData })`
When this is executed:
- The CustomEvent constructor is called.
- A new CustomEvent object is created with the specified event type (eventName) and custom data (detail).
- The detail property is initialized with the provided customData.
2. Dispatching the Event
- The
dispatchEvent
method is called on a target element/window to dispatch the custom event. - The browser’s event system takes over to manage the dispatching process
document.dispatchEvent(customEvent);
3. Event Propagation
Capturing Phase:
- The event starts at the root of the DOM and moves down to the target element.
- Event listeners set with
{ capture: true }
are triggered during this phase.
Target Phase:
- The event reaches the target element, and any event listeners registered directly on the target element are triggered.
Bubbling Phase:
- The event propagates up from the target element to the root of the DOM.
- Event listeners set with
{ capture: false }
are triggered during this phase.
document.addEventListener('myEvent', (event) => {
console.log(event.detail); // Outputs: { key: 'value' }
});
Event Handling
- The event listeners for the custom event are executed in the order they were added.
- The event object is passed to each listener, allowing them to access the custom data via the
detail
property.
Event Execution
- The JavaScript engine runs the event listener callbacks.
- In the event listener callback, the
event
object contains thedetail
property with the custom data.
Imagine a simple CRUD application that has 3 components -
An action bar that is purely a dispatcher — A component that doesnt necessarily need to listen to any changes, but will initiate changes.
We also have a chart, that is purely a listener, A component that will “react” to any event.
Finally we have the table, that does both, it can generate its own events, but it also has to listen to events coming from the action bar.
The mental model —
Software Architecture inherently is a mental model of how logic is placed & how it interacts internally. The mental model that some of us in this ecosystem are familiar with is the action-dispatcher-reducer based approach.
We can rely on the same tried and tested model, to then develop a framework for our dummy application
We start off by writing a set of “Actions” that one can dispatch —
NOTE: These actions are a “contract” between your microfrontend modules. The nature of contracts is that they need to be accessible by all parties involved, hence keep this in mind when it comes defining where these actions reside (It could be in a different, smaller shared package that all microfrontends consume)
export const MFE_ACTIONS = {
DELETE_ROW: "DELETE_ROW"
UPDATE_ROW: "UPDATE_ROW"
...
}
Since we do not have reducers in the typical sense here, we define functions that would return an object of the type
type EventObj = {
type: string;
options?: {
detail?: CustomEvent.detail
}
}
And then each function would perfrom a specific task, eg:
const deleteRow = (recordId: number) => {
return new CustomEvent({
type: MFE_ACTIONS.DELETE_ROW
options: { detail: { recordId: recordID }, bubbles: true, cancelable: true }
})
}
...
const updatedRows = (rows: Rows[]) => {
return new CustomEvent({
type: MFE_ACTIONS.UPDATE_ROWS,
options: { detail: { rows: rows }, bubbles: true, cancelable: true }
})
}
This helps us define a contract that mandates each MFE (MicroFrontend) to pass in a recordId
when deleting a row.
Lets see the call stack in action —
From our previous example, say someone clicks on Action t1 which would delete the row it is on.
Upon clicking this, the component would emit an event using
const MyCustomTableComponent = () => {
function deleteARecord(recordId:number) {
const deleteRowEvent = deleteRow(recordId); //This is the same function we declared previously
window.dispatchEvent(deleteRowEvent);
}
return (
<React.Fragment>
//Some JSX
</React.Fragment>
);
}
In the consumer (the view), we can have an API listener, which would subscribe to any delete events like so —
//useAPIListener.ts
function useAPIListener(){
useEffect(() => {
const listener = async ({ detail }) => {
const updatedRecords = await makeAPICallToDeleteRecord(detail.recordId);
window.dispatchEvent(updatedRows(updatedRecords));
}
window.addEventListener(MFE_ACTIONS.DELETE_ROW, listener)
return () => {
event.unsubscribe(MFE_ACTIONS.DELETE_ROW, listener);
}
}, []);
}
What this does is, that upon listening to a delete row event, it makes an API call to delete the row from the backend and then subsequently requests the new rows, and emits an events notifying everyone about the row change.
Our chart, could now be listening to this event of ‘MFE_ACTIONS.UPDATE_ROWS’ and then update its data accordingly.
This is what the end to end data flow would look like
A rarely talked about upside — Testing!
When all our components rely upon is events, these events can simply be generated in any test framework, even unit testing framworks such as jest can be used to see if the internals are working correctly since all we need to do is to generate an event!
If all components are well tested in a way such that
- They only emit legal events
- They consume legal events with their respective payload types
Then we can successfully test the components reaction to all possible mutations.
This becomes extermely handy since one would not need to invididually wrap test components around, say a QueryClient wrapper, mock a correct API response, mock a store, etc — everything is done by an event and only the useAPIListener would need to be tested for correctness for API calls, drastically reducing the intertia for testing.
Some other things to play around with —
An event also takes cancelable
, composed
, bubles
. These three can be poked around with, to introduce further fine-grained access to our Observer Pattern here.
Refer to the docs for more details!