As we all know, React allows developers to build applications quickly and easily, simplify the creation of custom interactive user interfaces, and focus on UI execution perfection.
When the application is created from scratch, everything seems easy and well connected, but as soon as the app grows, the components become confusing and mixed up with one another.
This article will unveil ways to avoid common mistakes during the growing phase of app development.
The plain react architecture
Most commonly, React suggests the simple division of all folders in the following way:
└── /src ├── /assets ├── /components ├── /context ├── /hooks ├── /pages ├── /services ├── /utils ├── App.ts ├── Routes.ts └── index.ts
This plain way of structuring the application works if we keep everything inside the same context, which is very useful for a mono-scope application (e.g. a to-do list): everything is separated by its own class type and you can write the application very fast by just following the web results.
The modularized React architecture
This is an evolution from the previous style, where you could handle multiple contexts:
└── /src ├── /assets ├── /firstModule | ├── /components | ├── /context | ├── /hooks | ├── /services | ├── /redux (module only) | └── /utils ├── /secondModule | ├── /components | ├── /context | ├── /hooks | ├── /services | ├── /redux (module only) | └── /utils ├── /shared | ├── /components | ├── /context | ├── /hooks | ├── /services | └── /utils ├── /redux (global) ├── /pages ├── App.ts ├── Routes.ts └── index.ts
This structure is more complex but allows a clearer separation between the various scopes: each module has its own components and should not talk to each other. The only exception is for the “Shared” folder, which is made exclusively to handle shared tools to be used everywhere.
The clean architecture (applied to React)
As many of you know, modern applications are divided into backend and frontend, often spread among different people with different projects, so modifications are done within separate pipelines.
Separate pipelines mean separate release timing.
So that also means that an API interface could change fast and/or break your frontend application in a very easy way.
To prevent this, we need to introduce the concept of layers.
Note that this kind of architecture is widely used in backend apps, to separate the database, business logic, and API sections but, unlike the backend one, we are not going to introduce the Dependency Injection or Inversion of Control patterns.
All three layers (presentation, application, and infrastructure) are made to communicate with their “siblings”, but the first and last cannot communicate with each other.
This separation is intentional since it allows relevant logic to be contained exclusively within the Application layer, which serves as a link between the other two.
This clear separation provides us with a few advantages in comparison to the previous architectures:
- Changes from backend will be confined inside the Infrastructure layer
- To handle multiple backend releases with breaking changes, we can still develop the new changes coming out and “switch” to the new one only when needed inside the application layer (maybe under a toggle or a fallback logic)
- Changes in styles will never impact application logic or backend calls
This is the common breakdown for the Clean Architecture applied to React (don’t worry if it’s complex, we will explain it in more detail):
└── /src ├── /assets ├── /presentation | ├── /pages | ├── /module1 | | ├── /pages | | ├── /components | | ├── /context | | └── /hooks | ├── /module2 | | ├── /pages | | ├── /components | | ├── /context | | └── /hooks | └── /shared | ├── /components | ├── /context | ├── /hooks | └── /utils ├── /application | ├── /types | ├── /services | └── /redux ├── /infrastructure | ├── /api | | ├── /mappers | | └── /dtos | ├── /websocket | | ├── /mappers | | └── /dtos | └── /otherservice | ├── /mappers | └── /dtos ├── App.ts ├── Routes.ts └── index.ts
Note: due to the highly nested levels, it is highly recommended to use the barrel files (aka index.ts) to shrink the imports.
The Presentation Layer
The presentation layer, which is responsible for displaying the user interface and interacting with the user, is usually one of the largest layers inside the structure, and reads from the application layer and displays the views to the user. It is split into the following parts:
├── /presentation ├── /pages ├── /module1 | ├── /pages | ├── /components | ├── /context | └── /hooks ├── /module2 | ├── /pages | ├── /components | ├── /context | └── /hooks └── /shared ├── /components ├── /context ├── /hooks └── /utils
The pages folder contains all the common pages to be displayed independently from the context (e.g. error pages, status pages, etc.)
The shared folder contains what could be useful to be reused multiple times inside the other modules, like the inputs, own style library, or common hooks and/or utilities.
The module folders contain all the other content useful to make the application visible and reactive. All of the items placed inside must be part of the same contextual idea; if something is shared with some other module, it should be moved into the “shared” folder.
The pages folder inside a single module is for page components made to match that specific route. Those components should only handle the routing parameters and/or access permissions, and then return some other components passing the right values as a prop.
To ensure the stability of this design, one rule must be followed strictly: modules should not import each other’s elements. If communication is needed, they should use the application layer or the shared folder items.
The infrastructure layer
The infrastructure layer is responsible for handling the communication with external services, like APIs, WebSockets, or third-party services.
├── /infrastructure ├── /api | ├── /mappers | ├── /dtos | ├── myApiMethod1.ts | └── myApiMethod2.ts ├── /websocket | ├── /mappers | └── /dtos └── /otherservice ├── /mappers └── /dtos
The folders are structured by Service and contain the same sub-structure inside each one. It is composed of:
Dtos folder, containing all the files to declare the service interfaces. It is strictly related to the service (e.g. API) and should not be adapted to the internal domain or logic.
Mappers folder contains classes (with their own tests) able to convert data from Dtos to application layers’ types. This conversion ensures the flexibility of the infrastructure layers during the changes.
The files inside the folders are all the declarations needed to make the communication work, including calling the service and returning a converted response using the mappers.
The application Layer
The application layer is responsible for applying business logic inside the app, it can interact with all the modules present in the presentation layer and call the services declared inside the infrastructure layer.
Types and classes are defined here in order to better satisfy the responsibilities at the mass center of the whole app.
├── /application ├── /types ├── /services └── /redux
Types folder contains interfaces, enumerators, and types needed to define all the data that flows inside the whole structure.
Services folders contain functions or classes made to bring calculations on the table or execute specific flows from/to the Infrastructure layer.
Redux contains the classic Redux structure to support his pattern: Actions, Reducers, Selectors, and Middlewares. The structure could change depending on external tools or add-ons.
A real project example
Picture this scenario: one of our customers asked to create an application to keep track of the bookings in his hotel. The application is composed of:
- An order section, made with the routes to view, create, edit and delete
- A dashboard page to display the sales of the month
So as soon as our backend people received the specifics, they made up this standard Rest API:
- /orders (get, post, put and delete methods to make all the CRUD operations)
- /dashboard (get only to retrieve the data)
So, after reading all of these, we made up this structure (explanation below):
└── /src ├── /assets ├── /presentation | ├── /pages | | ├── NotFoundPage.ts | | └── NotAllowedPage.ts | ├── /Orders | | ├── /pages | | | ├── OrdersListPage.ts | | | ├── OrdersCreatePage.ts | | | └── OrdersEditPage.ts | | ├── /components | | | ├── OrdersList.ts | | | ├── OrdersForm.ts | | | └── OrdersDeleteModal.ts | | ├── /context | | └── /hooks | ├── /Dashboard | | ├── /pages | | | └── DashboardPage.ts | | ├── /components | | | ├── DashboardTable.ts | | | └── DashboardGraph.ts | | ├── /context | | | └── DashboardGraphsContext.ts | | └── /hooks | | └── useDashboardGraphs.ts | └── /shared | ├── /components | | ├── Input.ts | | └── Button.ts | ├── /context | ├── /hooks | | └── useWindowResize.ts | └── /utils | └── dates-functions.ts ├── /application | ├── /types | | ├── orders.type.ts | | ├── order-status.enum.ts | | └── dashboard-data.type.ts | ├── /services | | ├── read-orders-list.ts | | ├── edit-orders.ts | | └── create-order.ts | └── /redux | ├── /orders | | └── (redux folders for orders) | └── /dashboard | └── (redux folders for dashboard) ├── /infrastructure | └── /api | ├── /mappers | | ├── ordersDtoToOrders.ts | | └── dashboardDtoToDashboardData.ts | ├── /dtos | | ├── get-order.dto.ts | | ├── post-order.dto.ts | | ├── put-order.dto.ts | | ├── delete-order.dto.ts | | └── get-dashboard.dto.ts | ├── orders-api.ts | └── dashboard-api.ts ├── App.ts ├── Routes.ts └── index.ts
The whole structure is made of multiple flows:
- Read order flow (Order List)
- Create or edit flows
- Read dashboard flow
Let’s try to follow just the reading of the order flow:
The data read by the API with a type defined in “get-order.dto.ts” passes through a mapper called “ordersDtoToOrders.ts”. This mapper transforms the DTO into the application layer type so that the DTO is only confined inside the infrastructure layer.
After that, the data moves to the presentation layer, and finally, displayed to the user.
With a create/modify operation, everything would follow the exact same flow.
Let’s make it a bit more complex, to showcase the flexibility of the process: what if the backend changes the DTO of the “GET /orders” but we cannot know the release time? (a valid statement if you consider this in a CICD environment).
So now, we ask our “read-orders-list.ts” to choose which API must be called depending on a flag.
If the flag is set to follow the new flow, the new API will be called and will use his new mapper to change the output.
The good news is that everything below the application layer remains untouched! (whew!).
When a project starts, it is easy to underestimate the growing complexity that comes along the way, in part because we start off not knowing how and in which direction the app will expand, or even because we might not expect any growth at all.
Just think about mono-task applications evolving in the course of a few months to meet customers’ needs and feedback. Flexibility and a change speed while keeping control of the code is a must in these situations, and a good solid design can make all the difference to build and foster a successful product.
This architecture tries to meet all these needs and requirements by separating the various layers and preventing the whole app from changing with every tiny implementation.
Interested in joining BOOM’s team of engineers and developers? Check out our current job offers.