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:
\ 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 startedWe decided to divide the project into modules using a modern tool for microfrontends. Here are the challenges we faced:
\ 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 sharingWhat’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\ 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 modulesThe 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\ 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.
ConclusionOur mistake was starting with a tool before logically dividing the modules. The correct order for modularisation in our case would have been:
\
\ 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.
All Rights Reserved. Copyright , Central Coast Communications, Inc.