Sometimes user interface fragments owned by different teams need to talk to each other. When a user adds an item to the basket by clicking the Buy button, other micro frontends like the mini basket want to be notified to update their content accordingly. We’ll take a more in-depth look at this topic in the first part of this chapter. But there are also other forms of communication going on in a micro frontends architecture, as you can see in figure 6.1.
In the second part of this chapter, we’ll explore how these types of communications play together. We’ll discuss how to manage state, distribute necessary context information, and replicate data between the team’s backends.
How can UIs from different teams talk to each other? If you’ve chosen good team boundaries, you’ll learn more about how to do it in chapter 13. There should be little need for extensive cross-UI communication in the browser. To accomplish a task, a customer is ideally only in contact with the user interface from one team.
In our e-commerce example, the process the customer goes through is pretty linear: finding a product, deciding whether to buy it, and doing the actual checkout. We’ve aligned our teams along these stages. Some inter-team communication might be required at the handover points when a customer goes from one team to the next.
This communication can be simple. We’ve already used page-to-page communication in chapter 2--moving from the product page to another team’s recommendation page via a simple link. In our case, we transferred the product reference, the SKU, via the
URL path or the query string. In most cases, cross-team communication happens via the URL.
If you are building a richer user interface that combines multiple use cases on one page, a link isn’t sufficient anymore. You need a standard way for the different UI parts to talk to each other. Figure 6.2 illustrates three common communication patterns.
We’ll go through all three forms of communication with a real use case on our product page. We’ll focus on native browser features in the examples.
The introduction of the Buy button on the product page resulted in a considerable amount of tractor sales over one weekend. But Tractor Model, Inc. has no time to rest. CEO Ferdinand was able to hire two of the best goldsmiths. They’ve designed special platinum editions of all tractors.
To sell these premium edition tractors, Team Decide needs to add a platinum upgrade option to the detail page. Selecting the option should change the standard product image to the platinum version. Team Decide can implement that inside their application. But most importantly, the Buy button from Team Checkout also needs to update. It must show the premium price of the platinum edition. See figure 6.3.
Both teams talk and come up with a plan. Team Checkout will extend the Buy button using another attribute called edition
. Team Decide sets this attribute and updates it accordingly when the user changes the option:
attributes: sku=[sku]
, edition=[standard|platinum]
example: <checkout-buy
sku="porsche"
edition="platinum"></checkout-buy>
The added option in the product pages markup looks like this.
... <img class="decide_image" src="https://mi-fr.org/img/fendt_standard.svg" /> ... <label class="decide_editions"> <input type="checkbox" name="edition" value="platinum" /> ❶ <span>Platinum Edition</span> </label> <checkout-buy sku="fendt" edition="standard"></checkout-buy> ❷ ...
❶ Checkbox for selecting the platinum option
❷ Buy button has a new edition attribute
Team Decide introduced a simple checkbox input element for choosing the material upgrade. The Buy-button component also received an edition
attribute. Now the team needs to write a bit of JavaScript glue-code to connect both elements. Changes to the checkbox should result in changes to the edition
attribute. The main image on the site also needs to change.
const option = document.querySelector(".decide_editions input"); ❶ const image = document.querySelector(".decide_image"); ❶ const buyButton = document.querySelector("checkout-buy"); ❶ option.addEventListener("change", e => { ❷ const edition = e.target.checked ? "platinum" : "standard"; ❸ buyButton.setAttribute("edition", edition); ❹ image.src = image.src.replace(/(standard|platinum)/, edition); ❺ });
❶ Selecting the DOM elements that need to be watched or changed
❷ Reacting to checkbox changes
❸ Determining the selected edition
❹ Updating the edition attribute on Team Checkout’s Buy-button custom element
❺ Updating the main product image
That’s everything Team Decide needs to do. Now it’s up to Team Checkout to react to the changed edition attribute and update the component.
The first version of the Buy-button custom element only used the connectedCallback
methods. But custom elements also come with a few lifecycle methods.
The most interesting one for our case is attributeChangedCallback (name
, oldValue
, newValue)
. This method is triggered every time someone changes an attribute of your custom element. You receive the name of the attribute that changed (name
), the attribute’s previous value (oldValue
), and the updated value (newValue
). For this to work, you have to register the list of attributes that should be observed up front. The code of the custom element now looks like this.
const prices = { porsche: { standard: 66, platinum: 966 }, ❶ fendt: { standard: 54, platinum: 945 }, ❶ eicher: { standard: 58, platinum: 958 } ❶ }; class CheckoutBuy extends HTMLElement { static get observedAttributes() { ❷ return ["sku", "edition"]; ❷ } ❷ connectedCallback() { this.render(); ❸ } attributeChangedCallback() { ❹ this.render(); ❹ } ❹ render() { ❺ const sku = this.getAttribute("sku"); ❻ const edition = this.getAttribute("edition"); ❻ this.innerHTML = ` <button type="button"> buy for $${prices[sku][edition]} ❼ </button> `; ... } }
❶ Added new prices for platinum versions
❷ Watching for changes to the sku and edition attribute
❸ Extracted the rendering to a separate method
❹ Calling render () on every attribute change
❻ Retrieves the current SKU and edition value from the DOM
❼ Renders the price based on SKU and edition
NOTE The function name render
has no special meaning in this context. We could have also picked another name like updateView
or gummibear
.
npm run 10_parent_child_communication |
Now the Buy button updates itself on every change to the sku
or edition
attribute. Run the preceding code and then go to http://localhost:3001/product/fendt in your browser and open up the DOM tree in the developer tools. You’ll see that the edition
attribute of the checkout-buy
element changes every time you check and uncheck the platinum option. As a reaction to this, the component’s internal markup (innerHTML
) of it also changes.
Figure 6.4 illustrates the data flow. We propagate changed state of the outer application (product page) to the nested application (Buy button). This follows the unidirectional dataflow 1 pattern. React and Redux popularized the “props down, events up” approach. The updated state is passed down the tree via attributes to child components as needed. Communication in the other direction is done via events. We’ll cover this next.
The introduction of the platinum editions resulted in a lot of controversial discussions in the Tractor Model, Inc. user forum. Some users complained about the premium prices. Others asked for additional black, crystal, and gold editions. The first 100 platinum tractors shipped within one day.
Emma is Team Decide’s UX designer. She loves the new Buy button but isn’t entirely happy about how the user interaction feels. In response to a click, the user gets a system alert
dialog, which they must dismiss to move on. Emma wants to change this. She has a more friendly alternative in mind. An animated green checkmark should confirm the add-to-cart interaction on the main product image.
This request is a bit problematic. Team Checkout owns the add-to-cart action. Yes, they know when a user successfully added an item to the cart. It would be easy for them to show a confirmation message inside the Buy-button fragment, or maybe animate the Buy button itself to provide feedback. But they can’t introduce a new animation in a part of the page they don’t own, like the main product image.
OK, technically they can because their JavaScript has access to the complete page markup, but they shouldn’t. It would introduce a significant coupling of both user interfaces. Team Checkout would have to make a lot of assumptions about how the product page works. Future changes to the product page could result in breaking the animation. Nobody wants to maintain such a construct.
For a clean solution, the animation has to be developed by Team Decide. To accomplish this, both teams have to work together through a clearly defined contract. Team Checkout must notify Team Decide when a user has successfully added an item to the cart. Team Decide can trigger its animation in response to that.
The teams agree on implementing this notification via an event on the Buy button. The updated contract for the Buy-button fragment looks like this:
Now the fragment can emit a checkout:item_added
event to inform others about a successful add-to-cart action. See figure 6.5.
Let’s look at the code that’s needed to make the interaction happen. We’ll use the browser’s native CustomEvents
API. The feature is available in all browsers, including older versions of Internet Explorer. It enables you to emit events that work the same as native browser events like click
or change
. But you are free to choose the event’s name.
The following code shows the Buy-button fragment with the event added.
class CheckoutBuy extends HTMLElement { ... render() { ... this.innerHTML = `...`; this.querySelector("button").addEventListener("click", () => { ... const event = new CustomEvent("checkout:item_added"); ❶ this.dispatchEvent(event); ❷ }); } }
❶ Creates a custom event named checkout:item_added
❷ Dispatches the event at the custom element
NOTE We’ve used a team prefix ([team_prefix]:[event_name]
) to clarify which team owns the event.
Pretty straightforward, right? The CustomEvent
constructor has an optional second parameter for options. We’ll discuss two options in the next example.
That’s everything Team Checkout needed to do. Let’s add the checkmark animation when the event occurs. We won’t get into the associated CSS code. It uses a CSS keyframe animation, which makes a prominent green checkmark character (✓) fade in and out again. We can trigger the animation by adding a decide_product--confirm
class to the existing decide_product
div element.
const buyButton = document.querySelector("checkout-buy"); ❶ const product = document.querySelector(".decide_product"); ❷ buyButton.addEventListener("checkout:item_added", e => { ❸ product.classList.add("decide_product--confirm"); ❹ }); ❸ product.addEventListener("animationend", () => { ❺ product.classList.remove("decide_product--confirm"); ❺ }); ❺
❶ Selecting the Buy-button element
❷ Selecting the product block where the animation should happen
❸ Listening to Team Checkout’s custom event
❹ Triggering the animation by adding the confirm class
❺ Cleanup--removing the class after the animation finished
Listening to the custom checkout:item_added
event works the same way as listening to a click
event. Select the element you want to listen on (<checkout-buy>
) and register an event handler: .addEventListener("checkout:item_added",
()
=> {...})
. Run the following command to start the example:
npm run 11_child_parent_communication |
Go to http://localhost:3001/product/fendt in your browser and try the code yourself. Clicking the Buy button triggers the event. Team Decide receives it and adds the confirm
class. The checkmark animation starts.
Using the browser’s event mechanism has multiple benefits:
Custom Events can have high-level names that reflect your domain language. Good event names are easier to understand than technical names like click
or touch
.
It gives access to all native event features like .stopPropagation
or .target
.
Let’s get to the last form of communication: fragment to fragment.
Replacing the alert dialog with the friendlier checkmark animation had a measurable positive effect. The average cart size went up by 31%, which directly resulted in higher revenue. The support staff reported that some customers accidentally bought more tractors than they intended.
Team Checkout wants to add a mini-cart to the product page to reduce the number of product returns. This way, customers always see what’s in their basket. Team Checkout provides the mini-cart as a new fragment for Team Decide to include on the bottom of the product page. The contract for including the mini-cart looks like this:
It does not receive any attributes and emits no events. When added to the DOM, the mini-cart renders a list of all tractors that are in the cart. Later the team will fetch the state from its backend API. For now, the fragment holds that state in a local variable.
That’s all pretty straightforward, but the mini-cart also needs to be notified when the customer adds a new tractor to the cart via the Buy button. So an event in fragment A should lead to an update in fragment B. There are different ways of implementing this:
Direct communication --A fragment finds the fragment it wants to talk to and directly calls a function on it. Since we are in the browser, a fragment has access to the complete DOM tree. It could search the DOM for the element it’s looking for and talk to it. Don’t do this. Directly referencing foreign DOM elements introduces tight coupling. A fragment should be self-contained and not know about other fragments on the page. Direct communication makes it hard to change the composition of fragments later on. Removing a fragment or duplicating one can lead to strange effects.
Orchestration via a parent --We can combine the child-parent and parent-child mechanisms. In our case, Team Decide’s product page would listen to the item_added
event from the Buy button and directly trigger an update to the mini-cart fragment. This is a clean solution. We’ve explicitly modeled the communication flow in the parent’s system. But to make a change in communication, two teams must adapt their software.
Event-Bus/broadcasting --With this model, you introduce a global communication channel. Fragments can publish events to the channel. Other fragments can subscribe to these events and react to them. The publish/subscribe mechanism reduces coupling. The product page, in our example, wouldn’t have to know or care about the communication between the Buy button and the mini-basket fragment. You can implement this with Custom Events. Most browsers2 also support the new Broadcast Channel API,3 which creates a message bus that also spans across browser windows, tabs, and iframes.
The teams decide to go with the event-bus approach using Custom Events. Figure 6.7 illustrates the event flow between both fragments.
Not only does the mini-cart need to know if the user added a tractor, it also must know what tractor the user added. So we need to add the tractor information (sku
, edition
) as a payload to the checkout:item_added
event. The updated contract for the Buy button looks like this:
Warning Be careful with exchanging data structures through events. They introduce extra coupling. Keep payloads to a minimum. Use events primarily for notifications and not to transfer data.
Let’s look at the implementation of this.
The Custom Events API also specifies a way to add a custom payload to your event. You can pass your payload to the constructor via the detail
key in the options object.
... const event = new CustomEvent("checkout:item_added", { bubbles: true, ❶ detail: { sku, edition } ❷ }*); this.dispatchEvent(event); ...
❷ Attaches a custom payload to the event
By default, Custom Events don’t bubble up the DOM tree. We need to enable this behavior to make the event rise to the window object.
That’s everything we needed to do to the Buy button. Let’s look at the mini-cart implementation. Team Checkout defines the custom element in the same fragment.js
file as the Buy button.
... class CheckoutMinicart extends HTMLElement { connectedCallback() { this.items = []; ❶ window.addEventListener("checkout:item_added", e => { ❷ this.items.push(e.detail); ❸ this.render(); ❹ }); ❷ this.render(); } render() { this.innerHTML = ` You've picked ${this.items.length} tractors: ${this.items.map(({ sku, edition }) => `<img src="https://mi-fr.org/img/${sku}_${edition}.svg" />` ).join("")} `; ... } } window.customElements.define("checkout-minicart", CheckoutMinicart);
❶ Initializing a local variable for holding the cart items
❷ Listening to events on the window object
❸ Reading the event payload and adding it to the item list
The component stores the basket items in the local this.items
array. It registers an event listener for all checkout:item_added
events. When an event occurs, it reads the payload (event.detail
) and appends it to the list. Lastly, it triggers a refresh of the view by calling this.render()
.
To see both fragments in action, Team Decide has to add the new mini-cart fragment to the bottom of the page. The team doesn’t have to know anything about the communication that’s going on between checkout-buy
and checkout-minicart
.
... <body> ... <div class="decide_details"> <checkout-buy sku="fendt" edition="standard"></checkout-buy> </div> <div class="decide_summary"> ❶ <checkout-minicart></checkout-minicart> ❶ </div> ❶ <script src="http://localhost:3003/static/fragment.js" async></script> </body> ...
❶ Adding the new mini-cart fragment to the bottom of the page
Figure 6.8 shows how the event is bubbling up to the top. You can test the example by running this command: |
It’s also possible to directly dispatch the Custom Event to the global window
object: window.dispatchEvent
instead of element.dispatchEvent
. But dispatching it to the DOM element and letting it bubble up comes with a few benefits.
The origin of the event (event.target
) is maintained. Knowing which DOM element emitted the event is helpful when you have multiple instances of a fragment on one page. Having this element reference avoids the need to create a separate naming or identification scheme yourself.
Parents can also cancel bubbling events on their way up to the window. You can use event.stopPropagation
on Custom Events the same way you would with a standard click event. This can be helpful when you want an event to only be processed once. However, the stopPropagation
mechanism can also be a source of confusion: “Why don’t you see my event on window
? I’m sure we’re dispatching it correctly.” So be careful with this--especially if more than two parties are involved in the communication.
In the examples so far, we’ve leveraged the DOM for communication. The relatively new Broadcast Channel API provides another standards-based way to communicate. It’s a publish/subscribe system which enables communication across tabs, windows, and even iframes from the same domain. The API is pretty simple:
In our case all micro frontends could open a connection to a central channel (like tractor_channel
) and receive notifications from other micro frontends. Let’s look at a small example.
const channel = new BroadcastChannel("tractor_channel"); ❶ const buyButton = document.querySelector("button"); buyButton.addEventListener("click", () => { channel.postMessage( ❷ {type: "checkout:item_added", sku: "fendt"} ❷ ); ❷ });
❶ Team Checkout connects to the central broadcast channel.
❷ They post an item_added message when someone clicks the Buy button. In this example we send an object, but you can also send plain strings or other types of data.
const channel = new BroadcastChannel("tractor_channel"); ❶ channel.onmessage = function(e) { ❷ if (e.data.type === "checkout:item_added") { ❷ console.log(`tractor ${e.data.type} added`); ❷ // -> tractor fendt added ❷ } ❷ };
❶ Team Decide also connects to the same channel.
❷ They listen to all messages and create a log entry every time they receive an item_added.
At the time of writing this book, all browsers except Safari support the Broadcast Channel API. 4 You can use a polyfill 5 to retrofit the API into browsers without native support.
The biggest benefit of this approach compared to the DOM-based Custom Events is the fact that you can exchange messages across windows. This can come in handy if you need to sync state across multiple tabs or decide to use iframes. You can also use the concept of named channels to explicitly differentiate between team-internal and public communication. In addition to the global tractor_channel
, Team Checkout could open its own checkout_channel
for communication between the team’s own micro frontends. This team-internal communication may also contain more complex data structures. Having a clear distinction between public and internal messages reduces the risk of unwanted coupling.
Now you’ve seen four different types of communication, and you know how to tackle them with basic browser features. You can, of course, also use custom implementations for communicating and updating components. A shared JavaScript publish/subscribe module which all teams import at runtime can do the trick. But your goal when setting up a micro frontends integration should be to have as little shared infrastructure as possible. Going with a standardized browser specification like Custom Events or the Broadcast Channel API should be your first choice.
In the last example, we transferred the actual cart line-item ({sku,
edition}
) via an event from one fragment to another. In the projects I’ve worked on, we’ve had good experiences with keeping events as lean and straightforward as possible. Events should not function as a way to transfer data. Their purpose is to act as a nudge to other parts of the user interface. You should only exchange view models and domain objects inside team boundaries.
As stated earlier, when you’ve picked your team boundaries well, there shouldn’t be a need for a lot of inter-team communication. That said, the amount of communication increases when you are adding a lot of different use cases to one view.
When implementing a new feature requires two teams to work closely together, passing data back and forth between their micro frontends, we have a reliable indicator of non-optimal team boundaries. Reconsider your boundaries and maybe increase the scope, or shift the responsibility for a use case from one team to another.
When using events or broadcasting, you have to keep in mind that other micro frontends might not have finished loading yet. Micro frontends are unable to retrieve events that happened before they finished initializing themselves.
When you use events in response to user actions (like add-to-cart), this is not a big issue in practice. But if you want to propagate information to all components on the initial load, standard events might not be the right solution.
So far, we’ve focused on user interface communication, which happens directly between micro frontends in the browser. However, there are other types of data exchange you have to solve when you build a real application. In the last part of this chapter, we’ll discuss how authentication, data fetching, state management, and data replication fit into the micro frontends picture.
Each micro frontend addresses a particular use case. However, in a non-trivial application, these frontends need some context information to do their job.What language does the user speak, where do they live, and which currency do they prefer? Is the user logged in or anonymous? Is the application running in the staging or live environment? These necessary details are often called context information. They are read-only by nature. You can see context data as infrastructure boilerplate that you want to solve once and provide to all the teams in an easily consumable way. Figure 6.9 illustrates how to distribute this data to all user interface applications.
We have to answer two questions when building a solution for providing context data:
Delivery --How do we get the information to the teams’ micro frontends?
Responsibility --Which team determines the data and implements the associated concepts?
Let’s start with delivery. If you’re using server rendering, HTTP headers or cookies are a popular solution. A frontend proxy or composition service can set them to every incoming request. If you’re running an entirely client-side application, HTTP headers are not an option. As an alternative, you can provide a global JavaScript API, from which every team can retrieve this information. In the next chapter, we’ll introduce the concept of an application shell. When you decide to go that route, putting the context information into the application shell is a typical pattern.
Let’s talk about responsibility. If you have a dedicated platform team, it’s also the perfect candidate to provide the context. In a decentralized scenario with no platform team, you’d pick one of the teams to do the job. If you already have a central infrastructure like a frontend proxy and an application shell, the owner of this infrastructure is a good candidate for also owning the context data.
Managing language preferences or determining the origin country are tasks that don’t require much business logic. For topics like authenticating a user, it’s harder. You should answer the question, “Which team owns the login process?” by looking at the team’s mission statements.
From a technical integration standpoint, the team that owns the login process becomes the authentication provider for the other teams. It provides a login page or fragment that other teams can use to redirect an unauthenticated user towards. You can use standards like OAuth 6 or JSON Web Tokens (JWT) to securely provide the authentication status to the teams that need it.
If you’re using a state management library like Redux, each micro frontend or at least each team should have its local state. Figure 6.10 illustrates this.
It’s tempting to reuse state from one micro frontend in another to avoid loading data twice. But this shortcut leads to coupling and makes the individual applications harder to change and less robust. It also introduces the potential that a shared state could get misused for inter-team communication.
To do its work, a micro frontend should only talk to the backend infrastructure of its team, as shown in figure 6.11. A micro frontend from Team A would never directly talk to an API endpoint from Team B. This would introduce coupling and inter-team dependencies. Even more important, you give up isolation. To run and test your system, the system from the other team needs to be present. An error in Team B would also affect fragments from Team C.
If your teams should own everything from the user interface to the database, each team needs its own server-side data store. Team Inspire maintains its database of manually crafted product recommendations, whereas Team Checkout stores all baskets and orders the users created. Team Decide has no direct interest in these data structures. They include the associated functionality (like recommendation strip or mini-cart) via UI composition in the frontend.
But for some applications, UI composition is not feasible. Let’s take the product data as an example. Team Decide owns the master product database. They provide back-office functionality, which employees of The Tractor Store can use to add new products. But the other teams also need some product data. Team Inspire and Team Checkout need at least the list of all SKUs, the associated names, and image URLs. They have no interest in more advanced information like editing history, video files, or customer reviews.
Both teams could retrieve this information via API calls to Team Decide at runtime. However, this would violate our autonomy goals. If Team Decide goes down, the other teams wouldn’t be able to do their job anymore. We can solve this with data replication.
Team Decide provides an interface that the other teams can use to retrieve a list of all products. The other teams use this interface to replicate the needed product information regularly in the background. You’d implement this via a feed mechanism. Figure 6.12 illustrates this.
When Team Decide’s application goes down, Team Inspire still has its local product database it can use to serve recommendations. We can apply this concept to other kinds of data.
Team Checkout owns the inventory. They know how many tractors are in stock and can estimate when new supplies arrive. If another team has an interest in this inventory data, they have two options: replicate the needed data to their application, or ask Team Checkout to provide an includable micro frontend that presents this information directly to the user.
Both are valid approaches that have their benefits and drawbacks. Team Decide can choose to replicate the inventory data if they want to build business logic that builds upon it. As an example, they might want to experiment with an alternative product detail layout for products that will run out of stock soon. To do this, they must know the inventory in advance, understand Team Checkout’s inventory format, and build the associated business rules.
Alternatively, if they just want to show the inventory information as simple text as part of the Buy button, UI composition is much more comfortable. Team Decide doesn’t have to understand Team Checkout’s inventory data model at all.
Communication between different micro frontends is often necessary at the handover points in your application. When the user moves from one use case to the next, you can handle most communication needs by passing parameters through the URL.
When multiple use cases exist on one page, it might be necessary for the different micro frontends to communicate with each other.
You can use the “props down, events up” communication pattern on a higher level between different team UIs.
A parent passes updated context information down to its child fragments via attributes.
Fragments can notify other fragments higher up in the tree about a user action using native browser events.
Different fragments that are not in a parent-child relationship can communicate using an event bus or broadcasting mechanism. Custom Events and the Broadcast Channel API are native browser implementations that can help.
You should use UI communication only for notifications, not to transfer complex data structures.
You can resolve general context information like the user’s language or country in a central place (e.g., frontend proxy or application shell) and pass it to every micro frontend. HTTP headers, cookies, or a shared JavaScript API are ways to implement this.
Each team can have its own user interface state (for example, a Redux store). Avoid sharing state between teams. It introduces coupling and makes applications hard to change.
A team’s micro frontend should only fetch data from its backend application. Exchanging larger data structures across team UIs leads to coupling and makes applications hard to evolve and test.
1.See http://mng.bz/pB72.
2.At the time of writing this, Safari is the only browser that hasn’t implemented it: https://caniuse.com/#feat=broadcastchannel.
3.See http://mng.bz/OMeo.
4.Broadcast Channel API--Browser Support: https://caniuse.com/#feat=broadcastchannel.
5.Broadcast Channel API--Polyfill: http://mng.bz/YrWK.
18.222.240.21