paint-brush
Our Journey to Micro-Frontendsby@iliaanisimov
266 reads

Our Journey to Micro-Frontends

by Ilia AnisimovOctober 22nd, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

How do successful projects usually get started? Quickly, without long discussions, in startup mode: we build features and deal with technical debt later. Under such conditions, it’s hard to establish an architecture that would allow the project to continue evolving after 5–7 years. The frontend part of our project was no exception. In this article, I’ll explain how we transitioned from a classic React application to a truly big project.
featured image - Our Journey to Micro-Frontends
Ilia Anisimov HackerNoon profile picture

When a project needs to be split

How do successful projects usually get started? Quickly, without long discussions, in startup mode: we build features and deal with technical debt later. Under such conditions, it’s hard to establish an architecture that would allow the project to continue evolving after 5–7 years. The frontend part of our project was no exception. In this article, I’ll explain how we transitioned from a classic React application to a truly big project.


So, we had a typical React 17 app. Redux + logic in thunks and middlewares. Custom SSR on Express. The project was actively growing, attracting new users, and gaining functionality. The complexity of the code increased, and after the Series A round of funding, the company allowed us to allocate up to 15% of our time to technical tasks. At this stage, the product development team began to split into “units”—this is what we call the teams which handle the app’s functionality (a specific user role or scenario). At this point, the frontend project saw its first optimisations:

  • Client-side lazy loading of components
  • Optimising the Redux middleware call stack


These optimisations allowed us to add new features to our React app for a few more years. The project eventually got Series B funding, so the number of frontend developers reached 30 and the size of the main bundle grew to 300 KB. The code's interdependence and task complexity indicated that it was time to break the app into parts.


We started by improving the asynchronous loading of components using React Loadable. This allowed us to load not just components asynchronously but entire pages. React Loadable gave us the ability, with our SSR, to understand which parts of the app were touched when rendering the HTML, collect those JS files, and load them before the first client render. After that, we realised that the main issue was in the middleware and thunks, not in the code of our pages. So, we began splitting the business logic within them.

Getting started

We decided to divide the project into modules using a modern tool for microfrontends. Here are the challenges we faced:

  • SSR Build — Most microfrontend tools didn’t support server-side rendering (SSR), so we manually adjusted the process to ensure all modules were bundled together during SSR.
  • Integration with Loadable — We needed a mechanism to determine which parts of the app were affected during SSR, so we had to integrate Loadable with the SSR modules. Since each module represented an independent Webpack build, Loadable couldn’t correlate the chunk names it needed with those already loaded out of the box. To fix this, we wrote a small Webpack plugin that preserved shared chunk names. We then exported the Webpack Stats file and taught Loadable to look for the necessary chunks across other modules during SSR.


At this point, we had a 4 KB overhead per module and a few asynchronous pages using the microfrontend tool, but it still didn’t solve the main issue—splitting the business logic. In the end, we put a lot of effort into implementing the tool without gaining significant optimizations. This happened because, regardless of the tool you use, what’s most important is how you logically split the app’s parts. After that, we moved on to directly dividing the business logic, having first solved a few related issues.

Data sharing

What’s stopping us from separating a module from our app? First of all, the shared stores. Let’s look at this problem using the example of the ‘users’ store. It looks something like this:


export type IUsersState = Record<IUserId, IUser>;
export const initialState: IUsersState = {}
export default function reducer(
    state: IUsersState = initialState,
    action: IAppAction,
): IUsersState {
    switch (action.type) {
        case USERS_SAVE: {
            return {
                ...state,
                ...action.users.reduce((acc, user) => {
                    acc[user.id] = user;
                    return acc;
                })
            }
        }
        // ...
    }
};


Each of our future modules uses such a store in roughly the following way. UserIds are stored in objects used by our module, and in selectors, we fill them with data from the users store. Why was it done this way at the start of the project? For convenience, caching, and data normalisation. Data from modules is stored in one place and, in some cases, can be reused from the cache instead of being requested again. The problem is that such a reducer is part of the common store, and if all modules have access to the shared store, it increases module interdependence. So, we extracted such reducers into separate stores using Zustand. This allowed us to isolate modules from data they didn’t need, while maintaining data sharing and reactivity between modules.

Event sharing between modules

The next issue we solved was shared middleware. Let’s break this down using the example of socket middleware. As the name suggests, this middleware processes events coming through a WebSocket connection. The problem is that this single middleware processes events for the stores of almost all modules. We reorganized it as follows: we created a separate event bus based on EventEmitter, independent of Redux, and now the module that maintains the WebSocket connection produces messages straight into the event bus. This way, we managed to split the common logic and distribute it across modules. An important point here: when switching from dispatching events to EventEmitter, you don’t need to migrate all events, just those used in multiple modules. Events that happen and are handled within one module don’t need to be migrated to EventEmitter. It’s crucial to understand the list of modules you want to isolate.

A few more things to mention

  • Upgrading to React 18 allowed us to remove some of the functionality of Loadable: there’s no longer a need to wait for all necessary chunks to load before performing the first render.
  • In order to reuse styles in common components, such as buttons (Button), we created a component library and started reusing them across the app.


After solving these issues, we began splitting the business logic into modules, reflecting the structure of how our company is divided into teams and areas of responsibility.

Conclusion

Our mistake was starting with a tool before logically dividing the modules. The correct order for modularisation in our case would have been:


  1. Logically divide the project into modules: make a list of modules.
  2. Solve the main issues with shared stores, events, and the base component library.
  3. Actually split the business logic.
  4. Implement improvements that are possible due to the modular system: separate deployment, isolation, etc.


And in conclusion, I want to wish good luck to everyone who is starting to split their application into modules. It's a long journey, but the result is worth it.