Styles Isolation in microfrontends with React (including Material styles)
Microfrontends is a power!
Microfrontends is an architectural style where a front-end application is decomposed into smaller, semi-independent “micro-applications”, that can be developed, tested, and deployed independently. This approach extends the concepts of microservices to the front end, enabling teams to work on different parts of a web application simultaneously and integrate them seamlessly. Microfrontends can significantly enhance the modularity and scalability of web applications, particularly for large organizations with multiple teams working on different parts of the application.
Looks interesting, doesn’t it? Wanna try? You might be surprised to learn that you’ve probably already used microfrontends in your projects. Typically, these are embedded components that enhance the functionality of your application. Here are a few examples:
Do you have a built-in chat solution with support on your website? Congratulations, that’s a microfrontend. Have you embedded a YouTube video? Believe it or not, that’s also a microfrontend! And the list goes on.
Nowadays, microfrontends are a powerful tool revolutionizing web development, gaining more and more popularity. However, while they offer significant benefits, they also introduce some challenges in application organization.
Why do we need to isolate styles?
One major challenge is the potential for style leaks between microfrontend applications. Just imagine the case when one microfrontend hosts another and both use the same UI libraries, but different theming variables, for instance pallets, or use identical classes for styling. Nested MFE will inherit all styles from its parent and some theming variables as well, classes can be overridden. If you don’t implement proper styles isolation you probably will see something like this:
This is why style isolation is crucial. Style isolation prevents conflicts between microfrontend styles, ensuring consistent design and predictable behavior, while allowing independent development without global style overrides. This simplifies maintenance, enhances modularity, supports scalability, ultimately ensuring a seamless user experience by avoiding visual inconsistencies and UI bugs.
Style isolation with Shadow DOM
There are many different approaches to style isolation in micro frontends (MFE). The best method depends on the specific requirements of each project. In this article we will consider the Shadow DOM approach. The Shadow DOM allows CSS encapsulation, creating a separate CSS world within an already loaded HTML page. This means that root/parent CSS cannot affect your styles, and your isolated CSS won’t affect the root/parent styles either. Let’s have a brief overview of its main advantages and disadvantages.
Advantages:
- True Encapsulation
Shadow DOM provides true encapsulation of styles and DOM, ensuring that the styles defined within a Shadow DOM do not leak out to the global scope and global styles do not affect the Shadow DOM. This level of encapsulation is more robust compared to other techniques like Naming Convention or CSS Modules, because styles in the the last two are still part of the global CSS and can be unintentionally overridden by more specific selectors. - Maintenance and Scalability
Easier to maintain because styles are self-contained within each component. Scales better as new components are added without the risk of style conflicts. - Native Browser Support
Shadow DOM is a web standard and is natively supported by modern browsers. This means there is no need for additional libraries or tooling to achieve style encapsulation, unlike CSS-in-JS solutions which require JavaScript runtime and build-time transformations.
Disadvantages:
- Styling Limitations
Global styles and CSS frameworks (like Bootstrap) do not penetrate the Shadow DOM, meaning components using Shadow DOM will not inherit global styles. This requires additional work to apply consistent styling across the entire application. Implementing consistent theming across components using Shadow DOM can be challenging, as each Shadow DOM instance encapsulates its styles. - Complexity
Implementing Shadow DOM can add complexity to the project. Developers need to understand the intricacies of the Shadow DOM API and how it interacts with the rest of the web application.
So, while the Shadow DOM approach offers many advantages such as encapsulation, ease of maintenance, predictability, and scalability, some limitations should also be taken into consideration.
Setup and goals
First, let’s briefly clarify our current setup. There are many approaches to building microfrontend applications. But in this article, we will focus on client-side rendering by splitting the application into host and remote modules:
- The host is the main application that serves as the container or shell for the microfrontends.
- The remote is an independently developed and deployed microfrontend that can be loaded into the host application.
To enable these components to work together, we will use Webpack Module Federation.
Now let’s set up our objectives:
- We have a Host (Shell). This serves as the entry point for the customer.
- The Host loads remotes using module federation and displays them as web components.
- We also have a remote application. This application is shared as a WebComponent, allowing the Host to integrate it. Simultaneously, it can function as a standalone app.
Our primary goal is to ensure that the Host and Remote operate in CSS isolation, preventing conflicts and ensuring safe development for both teams.
In this article, we will address several practical scenarios that will be beneficial for your development process. Most of the changes discussed will be on the Remote side.
React
Since React is the most popular library for frontend development, it’s important to discuss how to achieve our goals with React. One of the most widely used UI libraries for React is Material-UI (MUI). Integrating MUI with Shadow DOM in a React application can be challenging due to the style isolation inherent in Shadow DOM. However, it can be done with some additional steps. Here’s a straightforward step-by-step guide to achieve this:
Enable Shadow DOM
Let’s enable Shadow DOM at the application root in the index.tsx file. To style elements within the Shadow DOM, we can either include the styles via a link reference or a style tag directly inside the Shadow DOM. Since the Shadow DOM is isolated and external styles do not propagate into it, this step is necessary to ensure proper styling.
// index.tsx
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' })
this.style.display = 'contents'
this.root = createRoot(shadowRoot)
this.root.render(
<React.Fragment>
<link rel='stylesheet' href='index.css'></link>
<App />
</React.Fragment>
)
}
The code above creates a valid custom element that is transparent from a styling perspective (using display: contents). This means only its contents will be reflected in the render tree. It hosts a Shadow DOM containing a single style element, with the content of the style set to the text from the index.css file.
Styles injection
Make sure to place all your essential links in the index.tsx file rather than in the index.html file. If the parent application already contains these links, the cache will handle them, so there’s no need for concern. The main issue occurs if these links are not included at all. So, if you want to be sure that your remote application is completely independent, don’t forget about it. Let’s add it right below the stylesheet link.
// index.tsx
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet" />
Prevent inheritable styles leaking
Inheritance style leaking can happen with Shadow DOM because some CSS properties are naturally inheritable, like color, font-family, and line-height. Even though Shadow DOM is designed to encapsulate styles and prevent them from affecting or being affected by other parts of the document, these inheritable properties can still be passed down to elements inside the Shadow DOM unless they are explicitly set within the shadow root. If styles within the Shadow DOM are not properly scoped, unintended style inheritance can occur, leading to unexpected visual outcomes. To fix it just simply add this to index.scss:
// index.scss
:host {
all: initial;
}
All required styles should be added to styles.scss or index.tsx file. Additionally, any third-party styles, such as corporate themes and Material styles, need to be included.
Use MUI with shadow DOM
Hopefully MUI documentation provides a chapter on how to use Shadow DOM, for more information you can see these docs.
const cache = createCache({
key: 'css',
prepend: true,
container: shadowRoot
});
This creates a cache for Emotion (a CSS-in-JS library) to handle styles within the Shadow DOM. The container is set to shadowRoot to ensure styles are scoped within the Shadow DOM.
Than it should be provide in CacheProvider — provides the Emotion cache to ensure styles are scoped within the Shadow DOM.
Material UI components such as Menu, Dialog, and Popover use Portal to render a new “subtree” in a container outside of the current DOM hierarchy. By default, this container is document.body. However, because styles are applied only within the Shadow DOM, we need to render these portals inside the Shadow DOM container as well. Let’s update our code to incorporate this solution:
connectedCallback() {
const shadowRoot = this.attachShadow({ mode: 'open' })
this.style.display = 'contents'
const cache = createCache({
key: 'css',
prepend: true,
container: shadowRoot
})
const theme = createTheme({
components: {
MuiPopover: {
defaultProps: {
container: shadowRootElement,
},
},
MuiPopper: {
defaultProps: {
container: shadowRootElement,
},
},
MuiModal: {
defaultProps: {
container: shadowRootElement,
},
},
},
});
this.root = createRoot(shadowRoot)
this.root.render(
<React.Fragment>
<CacheProvider value={cache}>
<link rel='stylesheet' href='index.css'></link>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet" />
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>;
</CacheProvider>
</React.Fragment>
)
}
Conclusion
Managing CSS in a micro frontend solution doesn’t have to be difficult — it just needs to be approached in a structured and organized manner from the start. Otherwise, conflicts and problems will arise. There are various methods for achieving style isolation, with Shadow DOM being one of the fundamental approaches. We’ve tried to cover some of the most common use cases, but there is always room for improvement and further exploration.
This article was written in a strong collaboration with Oleksandr Koshevierov (@iworb). You can find more information about style isolation in Angular in his article, subscribe us to see more.