Mastering Communication and State Management in Microfrontends: Lessons from Unacademy
Microfrontends are one of the most exciting developments in modern front-end architecture. They allow large-scale applications to be broken down into smaller, independently developed, and deployed front-end applications. This modular approach enables teams to work autonomously, adopt different tech stacks, and deploy changes without affecting the entire application. However, with this freedom comes the challenge of managing communication and shared state across these microfrontends.
Having worked with microfrontends at Unacademy, where we dealt with massive user interactions and dynamic content, I faced firsthand the complexities and benefits of this architecture. In this blog, I’ll dive into the strategies that we used at Unacademy to manage state and communication between microfrontends, offering practical solutions, best practices, and lessons learned.
The Core Challenge: Communication and State Management
In a microfrontend architecture, each frontend is an independent application, typically built by different teams. This autonomy can lead to challenges in maintaining consistent data across microfrontends. For example:
How do you share user authentication data across different microfrontends?
How can one microfrontend trigger an action in another?
How can state be shared efficiently without coupling the microfrontends too tightly?
These are questions that come up frequently when working with microfrontends, and if not handled properly, they can lead to performance issues, inconsistent states, and frustrating bugs.
Key Concepts in Microfrontend Communication and State Sharing
State Management: How data (or state) is handled and updated across microfrontends.
Communication: How microfrontends interact with each other, whether through direct calls, events, or shared state.
Decoupling: Keeping microfrontends loosely coupled to maintain independent deployments and tech stacks.
Let’s look at how we addressed these issues at Unacademy using different strategies and tools.
1. Shared State Management: Centralizing with Redux or Global Stores
In a traditional single-page application (SPA), the state is often managed globally using tools like Redux or Context API (in React-based apps). The challenge in microfrontends is that each application may be running in isolation, and they may not share the same JavaScript context. So how do we maintain a shared global state?
Solution: Redux with a Twist
At Unacademy, we took advantage of Redux for state management in some of our microfrontends. To enable cross-micro frontend state sharing, we implemented a shared Redux store that was dynamically loaded into each microfrontend. Here’s how it worked:
Single Global Store: We created a central Redux store that could be imported into any microfrontend. This store was exposed through a shared library (more on sharing code later).
State Synchronization: When a user logged in or updated their profile in one microfrontend, that state change was immediately reflected across other microfrontends. For example, if the user updated their profile in the dashboard microfrontend, the changes would be instantly visible in the header microfrontend.
Key Steps:
- Create a Shared Redux Store: We created a shared Redux store in a separate package, which was then imported by each microfrontend.
// shared-store.js
import { createStore } from "redux";
const initialState = { user: null };
function rootReducer(state = initialState, action) {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.payload };
default:
return state;
}
}
export const store = createStore(rootReducer);
2. Connect Microfrontends to the Store: Each microfrontend imported the shared store and connected it using Redux’s Provider
component.
// Microfrontend A
import React from "react";
import { Provider } from "react-redux";
import { store } from "@unacademy/shared-store";
import App from "./App";
function MainApp() {
return (
<Provider store={store}>
{" "}
<App />{" "}
</Provider>
);
}
export default MainApp;
3. Dispatch Actions Across Microfrontends: Actions were dispatched from one microfrontend, and all microfrontends responded to those actions.
// Microfrontend B
store.dispatch({ type: "SET_USER", payload: { name: "John Doe" } });
Best Practice: While sharing a global store can be effective, it’s important to avoid over-reliance on it. We minimized the use of shared global state, preferring local state where possible to maintain independence between microfrontends.
2. Event-Driven Communication: Pub/Sub Patterns and Event Bus
Another effective strategy we used at Unacademy was event-driven communication. This pattern allowed microfrontends to communicate by broadcasting and subscribing to events without directly depending on each other. It provided a more decoupled way of sharing information across microfrontends.
Using the Event Bus Pattern
We implemented a custom event bus using the native JavaScript CustomEvent
API. This allowed one microfrontend to broadcast an event and other microfrontends to react to it.
How it Works:
Broadcast Events: A microfrontend dispatches an event (e.g., a user logs in, a product is added to the cart).
Listen to Events: Other microfrontends listen for these events and respond accordingly.
Implementation Example:
- Broadcasting an Event: In Microfrontend A, we triggered an event when the user logged in:
// Microfrontend A - Broadcasting Event
const user = { id: 123, name: "John Doe" };
const event = new CustomEvent("userLoggedIn", { detail: user });
window.dispatchEvent(event);
2. Listening for the Event: In Microfrontend B (e.g., a header component), we listened for the event and updated the UI:
// Microfrontend B - Listening to Event
window.addEventListener("userLoggedIn", (event) => {
const user = event.detail;
console.log(`User logged in: ${user.name}`); // Update header UI
});
This approach allowed microfrontends to remain loosely coupled — they didn’t need to know about each other, only about the events they cared about.
Best Practice: Pub/Sub patterns are great for loosely coupling microfrontends, but be mindful of overusing events. Too many events can lead to a situation where it’s difficult to track what is happening, especially in large-scale applications.
3. Shared Code: Leveraging Webpack Module Federation
One of the most exciting advancements we implemented at Unacademy was the use of Webpack 5’s Module Federation to share code dynamically between microfrontends. This approach allowed us to share services, components, and utilities across independently deployed microfrontends at runtime.
The Power of Module Federation
Before Module Federation, we were using shared NPM packages to share code between microfrontends, but this required rebuilding and redeploying each microfrontend whenever the shared code was updated. Module Federation solved this by enabling microfrontends to load shared code dynamically at runtime.
Implementation Steps:
- Expose Shared Code: In the microfrontend that contained the shared code (e.g., a user service), we exposed the code via Webpack.
// webpack.config.js in Microfrontend A
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "microfrontendA",
filename: "remoteEntry.js",
exposes: { "./UserService": "./src/services/UserService" },
}),
],
};
2. Consume Shared Code: In Microfrontend B, we consumed the shared code dynamically at runtime:
// webpack.config.js in Microfrontend B
module.exports = {
plugins: [
new ModuleFederationPlugin({
remotes: {
microfrontendA: "microfrontendA@http://localhost:3001/remoteEntry.js",
},
}),
],
}; // In code import { getUser } from 'microfrontendA/UserService';
Why This Worked Well for Us:
Runtime Sharing: We could share and update code across microfrontends without forcing a redeploy of all the microfrontends.
Performance Gains: By sharing common code (like authentication services and utility functions), we reduced redundancy and improved performance.
Best Practice: Keep the shared modules minimal to avoid tight coupling between microfrontends. Only share code that is truly common across applications (e.g., authentication services, utility libraries).
4. URL-Based Communication
At Unacademy, some microfrontends needed to share state in ways that didn’t involve shared stores or events. For certain use cases, we opted for URL-based communication, passing data through URL query parameters or fragments. This approach was particularly useful for triggering navigation-related actions.
Example:
Imagine a scenario where one microfrontend controls the navigation while another displays content. By using URL query parameters, we passed state from one microfrontend to another.
- Set Query Parameters: In Microfrontend A (e.g., a search bar), the user entered a search query, which updated the URL:
// Microfrontend A - Setting URL Parameters
function handleSearch(query) {
const url = new URL(window.location);
url.searchParams.set("q", query);
window.history.pushState({}, "", url);
}
2. Read Query Parameters:
In Microfrontend B (e.g., a search results page), we read the query parameters from the URL to display the relevant search results:
// Microfrontend B - Reading URL Parameters
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get("q");
if (searchQuery) {
fetchSearchResults(searchQuery);
}
This simple approach allowed microfrontends to communicate without being directly dependent on each other. It worked particularly well for navigation-driven state sharing, like search parameters, filters, or pagination.
Best Practice: URL-based communication works well for scenarios where the state is naturally tied to the navigation (e.g., filters, search queries). However, be mindful of how much data you store in the URL, as it’s exposed to users and can clutter the address bar.
5. Microfrontend-Specific APIs
While shared state or events work in many cases, there are scenarios where microfrontends need a direct, controlled way to interact with each other. At Unacademy, we sometimes used microfrontend-specific APIs, where each microfrontend would expose certain functions that other microfrontends could call.
How It Worked:
Each microfrontend provided a clear API, allowing other microfrontends to call specific functions without requiring tight coupling. This allowed us to maintain a separation of concerns while still enabling communication.
Example:
- Expose an API in Microfrontend A: Microfrontend A (e.g., a user authentication component) exposed an API that other microfrontends could use to get the current user.
// Microfrontend A - Exposing an API export
const UserAPI = {
getCurrentUser() {
return { id: 123, name: "John Doe" };
},
};
2. Use the API in Microfrontend B: Microfrontend B (e.g., a dashboard) could then import and use this API without being tightly coupled to Microfrontend A.
// Microfrontend B - Consuming the API import { UserAPI } from 'microfrontendA/UserAPI';
const currentUser = UserAPI.getCurrentUser();
console.log(`Logged in user: ${currentUser.name}`);
This approach kept microfrontends loosely coupled while allowing them to interact in a structured way.
Best Practice: Use microfrontend-specific APIs sparingly. They are best suited for scenarios where microfrontends need to expose specific, well-defined actions or data to other microfrontends, while still maintaining clear boundaries and minimizing dependencies.
6. Pub/Sub Patterns for Decoupled Communication
In cases where microfrontends needed to be highly decoupled but still communicate efficiently, we implemented pub/sub (publish/subscribe) patterns. This event-driven architecture allowed microfrontends to publish events without needing to know which microfrontends were subscribed to those events.
How It Worked:
Publisher: One microfrontend would publish an event (e.g., a user logs out).
Subscriber: Any number of microfrontends could subscribe to the event and respond to it (e.g., clearing user data from local storage).
Example:
- Publishing an Event: In Microfrontend A, an event was published when the user logged out.
// Microfrontend A - Publishing an Event
const eventBus = new EventTarget();
eventBus.dispatchEvent(new CustomEvent("userLoggedOut"));
2. Subscribing to the Event: In Microfrontend B, the application subscribed to the logout event and cleared the relevant state when the event was triggered.
// Microfrontend B - Subscribing to the Event
const eventBus = new EventTarget();
eventBus.addEventListener("userLoggedOut", () => {
localStorage.clear();
console.log("User logged out. Clearing local data.");
});
This pub/sub architecture allowed microfrontends to stay decoupled while still enabling real-time communication.
Best Practice: Pub/sub patterns are useful when you want to keep microfrontends completely independent. However, it’s essential to have good documentation and monitoring of events, so developers can easily track and manage subscriptions.
Lessons Learned from Unacademy
At Unacademy, where performance and scale were critical, adopting a microfrontend architecture required thoughtful planning and constant iteration. Here are some key lessons we learned:
Avoid Over-Coupling: One of the biggest challenges in microfrontends is preventing unnecessary coupling between applications. While shared state can be useful, it’s essential to minimize its use and keep microfrontends as independent as possible.
Modular, Decoupled Communication: Event-driven architectures (using pub/sub patterns or event buses) helped us decouple microfrontends effectively. This ensured that teams could develop, test, and deploy their microfrontends independently without worrying about dependencies.
Dynamic Code Sharing via Module Federation: Webpack Module Federation became a game-changer, allowing us to share code dynamically at runtime. This reduced build times and enabled us to keep microfrontends up-to-date with shared services or libraries without requiring constant redeployments.
Clear API Contracts: When microfrontends did need to communicate directly, having clearly defined APIs made the integration smoother and more manageable. This helped maintain the autonomy of each microfrontend while ensuring they could collaborate when necessary.
Balance Between State Sharing and Isolation: While global state sharing (like with Redux) provided a consistent experience across microfrontends, we found that isolating state within individual microfrontends reduced complexity and prevented unexpected behaviors. A good balance between local and shared state is crucial.
Conclusion: Microfrontends Offer Flexibility, but Require Careful Planning
Microfrontends offer unparalleled flexibility and scalability for large-scale applications, but they come with challenges. Managing communication and state between microfrontends requires careful consideration to avoid coupling, performance issues, and complexity.
At Unacademy, we experimented with and implemented various strategies — from shared Redux stores to event-driven communication and Module Federation. These tools allowed us to keep our microfrontends loosely coupled, highly scalable, and independently deployable. The key is to carefully choose the right strategy based on your specific use case and maintain a balance between shared resources and independence.
If you’re working with or considering microfrontends in your architecture, I hope the lessons from Unacademy help guide you toward a more modular and efficient frontend architecture.
Do you have questions or want to chat more about microfrontends? Feel free to connect with me!
Follow me on https://x.com/piyushsingh099
Connect with me at https://www.linkedin.com/in/piyush0992/