In the ever-evolving landscape of web development, the need for scalable, maintainable, and flexible architectures has never been greater. Enter micro frontends, a design approach that breaks down the frontend monolith into smaller, more manageable pieces. Much like microservices have revolutionized backend development by decomposing complex applications into smaller services, micro frontends aim to achieve the same for the user interface. The benefits of this approach are manifold:
- Scalability: By breaking down the frontend into smaller units, teams can scale development processes more efficiently.
- Independence: Different teams can work on different parts of the frontend simultaneously without stepping on each other's toes.
- Flexibility: It's easier to experiment with new technologies or frameworks when you're working with a smaller piece of the puzzle.
- Resilience: A failure in one micro frontend doesn't necessarily bring down the entire application.
- Reusability: Components or entire micro frontends can be reused across different parts of the application or even different projects.
Given these advantages, it's tempting to dive right into micro frontends. However, it's crucial to understand the architectural choices available and their implications. Broadly speaking, there are two prevalent approaches:
1.Central Orchestrator Model: In this approach, there's a primary application that acts as the orchestrator. It's responsible for mounting and unmounting micro frontends based on user interactions or other triggers. This centralized control can simplify state management and inter-micro frontend communication. However, a significant challenge arises when different micro frontends use different frameworks. Even varying versions of the same framework can pose integration challenges. Consistency in technology choices becomes essential for smooth operation.
2.Route-Driven Fragmentation: Here, the main application is stripped down to its bare essentials, primarily handling routes. Each route or link corresponds to a micro frontend, making this approach particularly suitable for dashboards or applications where each view is distinct. The primary advantage is the flexibility it offers. Since each micro frontend is loaded independently based on routes, there's greater freedom in choosing frameworks or technologies for each one. Teams can pick the best tool for the job without being constrained by the choices of other micro frontends.
State Management in Micro Frontends
State management is a cornerstone of any frontend application, determining how data flows, is stored, and is manipulated. When diving into the realm of micro frontends, the challenge amplifies, given the distributed nature of the architecture. Let's explore how state management varies between the two primary micro frontend architectural approaches.
Central Orchestrator Model
In this approach, the overarching application acts as the central hub, making it conducive to employ a centralized state management system. Tools like Redux, Vuex, or NgRx can be seamlessly integrated, allowing for a unified store that holds the global state.
Programmatic State Passing: The main application can pass down relevant parts of the state to individual micro frontends as they are mounted. Depending on the framework, this can be achieved through props, context, or other mechanisms. This ensures that each micro frontend has access to the data it needs without being overwhelmed by the entirety of the global state.
Modularity with Centralization: Even though the state is centralized, it doesn't mean everything is lumped together. Middleware, actions, reducers, or equivalent constructs can be organized around individual micro frontends. This ensures modularity and maintainability while benefiting from a unified data store.
Route-Driven Fragmentation
Given the isolated nature of micro frontends in this approach, state management tends to be more decentralized.
URL Params: State relevant to navigation or user interface settings can be encoded in the URL. This allows for deep linking, where users can bookmark or share specific application views. For instance, a dashboard's filter settings might be represented as URL parameters, ensuring consistent views upon navigation.
Global Window Variables: While not always recommended due to potential risks like accidental overwrites, global window variables can serve as a mechanism to share state or functions between micro frontends. However, care must be taken to ensure encapsulation and avoid naming collisions.
External State Stores: To achieve a shared state without relying on the main app, micro frontends can resort to external state stores or services. Backend APIs, browser databases like IndexedDB, or even cloud-based real-time databases can be employed. This allows micro frontends to fetch and update shared state independently.
In essence, while the "Central Orchestrator Model" approach leans towards a more centralized state management system, the "Route-Driven Fragmentation" approach demands a more decentralized and strategic approach to handle state. Both methods come with their set of challenges and advantages, and the choice largely depends on the specific needs of the application and the preferences of the development team.
Technical Implementation
In the realm of Micro Frontends, whether you opt for the Central Orchestrator Model or the Route-Driven Fragmentation approach, the technical implementation plays a crucial role. There are several interesting options available, from Import Maps for controlled module loading to leveraging the dynamic module capabilities of SystemJS. Module Federation offers a way to seamlessly share dependencies across builds, while Single SPA provides a comprehensive solution specifically designed for managing Micro Frontends and often suggests using SystemJS for optimal module loading. Each of these options has its own pros and cons.
I conclude this article with an example, demonstrating how to use SystemJS to implement Micro Frontends without additional frameworks. The advantage of SystemJS over Webpack Module Federation is that it does not bind you to a specific bundler.
-
Structure of the Micro Frontend:
The Micro Frontend defines two functions,
mount
andunmount
insrc/main.tsx
, which enable the main application to control the loading and unloading of the Micro Frontend.
// src/main.tsx
let rootInstance: Root | null = null;
export function mount(containerId: string, token: string) {
const container = document.getElementById(containerId);
if (!container) {
console.error(`Container with id "${containerId}" not found.`);
return;
}
initializeFirebase(token);
rootInstance = createRoot(container);
rootInstance.render(
<ChatPartnerProvider>
<App />
</ChatPartnerProvider>
);
}
export function unmount(containerId: string) {
if (rootInstance) {
rootInstance.unmount();
rootInstance = null;
} else {
console.error(`Application not mounted to "${containerId}"`);
}
}
-
Dynamic Loading through the Main Application:
The main application uses SystemJS to dynamically load the Micro Frontend. The
loadMicroFrontend
function loads the bundled Micro Frontend from a Google Cloud Storage Bucket.
let microFrontendPromise: Promise<any> | null = null;
export type MicroFe = {
mount: (containerId: string) => void,
unmount: (containerId: string) => void,
};
export const loadMicroFrontend = async (): Promise<MicroFe | undefined> => {
if (!microFrontendPromise) {
microFrontendPromise = System.import("https://some-bucket.com/chat-micro-fe/main-chat-fe.js")
.then((module) => {
return { ...module };
})
.catch((err) => {
microFrontendPromise = null;
throw err;
});
}
return microFrontendPromise;
};
-
Integration and Control by the Main Application:
The
MicroFrontend
component in the main application usesuseEffect
to mount the Micro Frontend upon loading and to unmount it upon removal.
export default function MicroFrontend({ containerId }: MicroFrontendProps) {
useEffect(() => {
let microFe: MicroFe | undefined;
const loader = async () => {
microFe = await loadMicroFrontend();
microFe && microFe.mount(containerId);
};
loader();
return () => microFe && microFe.unmount(containerId);
}, [containerId]);
return <div id={containerId} />;
}
-
Handling Shared Dependencies:
The main application's HTML document provides shared dependencies through a
systemjs-importmap
.
<!DOCTYPE html>
<html lang="en">
<head>
<script type="systemjs-importmap">
{
"imports": {
"react": "/react.development.js",
"react-dom": "/react-dom.development.js",
"@mui/material": "/material-ui.production.min.js"
}
}
</script>
</head>
<body>
...
</body>
</html>
As always, I'm grateful for any feedback.
Cheers,
Hamed
Top comments (7)
One technical point emphasized in the article is the use of dynamic loading through the main application using SystemJS. This approach allows for efficient loading of micro frontends on-demand, enhancing performance by fetching only the necessary resources when required. By leveraging SystemJS's capabilities for dynamic module loading, developers can achieve a more streamlined and responsive user experience, minimizing initial load times and optimizing resource utilization.
time well spent to read this
Thank you very much Hamed!
I'm a big fan of this solution. Creating a micro frontend without any frameworks (single spa, tailor) and module loader dependencies.
@efpage Here you can find a working base implementation:
https://github.com/artursopelnik/microfrontend-with-systemjs
We have been using this approach in our application for over a year and have had no issues with it.
in my project I'm facing the problem with shared dependencies. No matter how I try, it always load multiple instances of react and react-dom. I'm using vite module federation for shared dependecies but now I'm looking for another solution. Do you think SystemJS will help me?
Is there any example of a working code? Would be interesting to see the runtime effects of this approach.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.