If you’re reading this article, you’ve probably already understood the importance of having clean architecture within your app. And if you’ve tried applying those clean architecture principles to your React front-end, you’ve likely hit a wall (I know, I’ve been there too).
In this blog post, I don’t want in a million time explain why clean architecture matters, like so many great authors did before me. I want to focus on rather how to implement it, specifically on the front-end using React and TanStack Router.
I’ve been carefully crafting and experimenting with the approach I’m about to show you for years on dozens of production and side projects. My goal now is to help you implement the same approach in your own projects.
TIP: If you want to understand the fundamentals of software architecture, I recommend choosing the book called “Clean Architecture: A Craftsman's Guide to Software Structure and Design” by R. Martin as a starting point.
First things first: Why TanStack Router?
While it comes with some great features like a file-based routing system and router-related code generation, regarding the architectural aspects, the official documentation doesn’t provide you with any information. But that’s exactly what I like about TanStack Router: it’s a robust yet highly adaptable client-side framework. You can build any architecture with it, including the hero of today’s article.
Ladies and gents, meet MVVM
MVVM is a common design pattern that you can use to:
- Have a clear separation of concerns between UI, business logic, data accessing, and interfaces or types.
- Integrate tools like plop.js to automate the creation of the new modules within your app with all the required concerns.
- Simplify unit tests writing by focusing on one piece of functionality at a time. It simply means that with MVVM, you can test business logic without worrying about UI and vice versa.
NOTE: When I say “modules within your app”, I mean Authentication, Shopping Cart, Home, About, and other pages in your app. You might also know them as features.
How can MVVM gift me all these sweets?
By breaking all the modules in your app into:
- Models - a place where data access and business logic handling are happening.
- Views - UI (widgets or components).
- ViewModels - serve as an orchestrator between models and views.
More practical approach
Above, I’ve described the theory of MVVM with three core parts. In practice, I break MVVM into five more granular layers:
- Data, Application, and Model correspond to the Model layer in the original MVVM.
- View stays the same in both the original and the implementation I’m about to show you.
- Orchestrating component used as a ViewModel.
This way, each concern stays isolated, making things easier to test and maintain. Each layer handles its specific task, and there's a clear way in which they interact with one another.
NOTE: Naming of the layers is totally up to you. I just recommend keeping it consistent to ensure clarity and speed up onboarding.
Here’s the preview:
Now let’s take a closer look at each layer.👇
NOTE: Because there's a lot to explain, this article focuses on a broad, high-level look at the layers. You can find links below to individual articles that dive deeper into each layer with code examples. I recommend following the order of layers beneath when exploring each one individually. Thus, you don’t miss anything and understand all the details.
Data Layer
This layer communicates with the external source(-s). For example, it can perform API calls, make requests to localStorage, and read or write data into a file. Think of the Data Layer as “raw” access: it only knows how to fetch.
The layer contains a well-known class(-es) called the repository pattern, which is used to access the external data. The repository has methods, each representing one operation.
Suppose we need to fetch a list of all products. We’d create a method inside the repository called findAllProducts
which:
- Makes a GET request to an external source.
- Defines a type for the raw JSON response.
- Returns type-safe data to the consumer who called the method.
Learn more about the Data layer and repositories.
Model Layer
Now that we’ve fetched the data, let’s add some types for it.
The Model layer defines types, interfaces, and classes, including:
Payloads
TypeScript interfaces or types that define a structure for the untyped data (like JSON) we receive from the external source. Suppose findAllProducts
receives the next JSON from the API:
[
{
"id": "prod_12345",
"name": "Wireless Headphones",
"description": "Noise-cancelling over-ear Bluetooth headphones",
"price": 149.99,
"currency": "USD",
"//other": "properties...."
}
]
So the payload type is the following:
export type ProductPayload = {
id: string;
name: string;
description: string;
price: number;
currency: string;
// other properties...
};
Of course, it should be an array in our case: ProductPayload[]
Data Transfer Objects (DTOs)
TypeScript interfaces or types that describe the structure for the untyped data (like JSON) sent to the external source.
Following the example from the previous section, we can specify a DTO type for the query parameters of the findAllProducts
method and call it FindAllProductsDTO
. We’ll assume it contains one parameter called category
, so the whole DTO type will look like:
type FindAllProductsDTO = {
category: string
}
TIP: Find out more about DTOs in this amazing article by Miłosz Piechocki.
Module Type
The main type of our module. It holds all the properties that will be displayed on the UI or used for business logic within the module.
Let’s say we need to display the data that findAllProducts
returns to us. For that, we may want to merge the price
and currency
of ProductPayload
into a single price
property. So the Product
module type will look like this:
type Product = {
id: string;
name: string;
description: string;
price: string;
// other properties...
}
With this structure, we can conveniently display all the product info on the UI.
Prop types
Simple type objects for React components (widgets and orchestrating component in our case) that we all know.
TIP: Follow the next sections to learn what widgets and orchestrating component are.
Learn more about the Model layer.
View layer
This layer displays the data and serves as a primary point of user interactions. The layer is divided into widgets - individual components that render the UI and respond to events. In most cases, all the widgets must be pure React components without any business logic inside and with a single return of JSX.
Assume we want to display our list of products on the Home page. We’d create a widget called ProductsList.tsx
, and one of the props for it would be an array of products - Product[]
. Later, we can iterate through the array and display the products like a list of cards.
Learn more about the View layer.
Application Layer
Now that we can receive and display typed data, it’s time to handle business logic (use cases, if you prefer), and that’s where the Application layer comes in.
Some of the common tasks for this layer may include:
- Map the data before sending it to the View layer
- Make conditional requests to the Data layer - decide when and why to fetch the data.
- Initialize functions that will handle user interactions: clicks on buttons, filling a form, scrolling, etc.
In React, I prefer to implement the Application layer as a group of hooks. Usually, each hook corresponds to a specific widget from the View layer, although sometimes we can pass the data from a single hook to multiple widgets. For example, for ProductsList.tsx
, we would create a hook called useProductsList.tsx
. This hook would handle all the business logic for the widget.
Learn more about the Application layer.
Orchestrating component
Once our business logic is isolated, it’s time to glue everything together. This is exactly the role of the Orchestrator.
You can probably guess from the name that the Orchestrating component connects all four layers and makes them work together. We need the Orchestrator since we can’t simply call hooks from the Application layer into View widgets, because then widgets won’t be clean components anymore.
If we had a Home page that should show a list of products, the Orchestrator would first call hooks from the Application layer. Then, it would pass the data returned from each hook to the appropriate widget in the View layer, and finally return those widgets as JSX.
import { useProductsList } from './application/useProductsList';
import { ProductsList } from './view/ProductsList';
import { Loading } from '~/components/Loading';
import { Error } from '~/components/Error';
export const Home = () => {
const { products, isLoading, error } = useProductsList();
// You can handle loading and error state in the Orchestrator or in each widget individually
if (isLoading) return <Loading />;
if (error) return <Error />;
return <ProductsList products={products} />;
};
Learn more about Orchestrator.
Disadvantages
As with everything in the world, this architecture is not perfect either. Here are some downsides that I’ve spotted while I was implementing it on a variety of projects:
- Adoption - when you try to implement this architecture on the existing project, you may face rejection from your colleagues, claiming that it’ll bring complexity and unnecessary boilerplate to the codebase. In this situation, don’t rush refactoring the whole project. Start slow, implement new or refactor existing small module using MVVM. Document the real benefits and show them to the doubters.
- More boilerplate - yes, MVVM will bring more boilerplate, but you can automate its creation using tools like plop.js and stop wasting time. Eventually, you will even win from having a standardized structure that you can bootstrap automatically, as it will bring order to the project.
Conclusion
Congratulations! 🎉
You’ve learned the fundamentals of MVVM and its practical five-layer implementation. We’ve discussed how to CRUD the data, type it, apply the business logic, and present it on the UI.
Want to go deeper? Use the links at the end of each section, or start experimenting right away using this high-level overview I’ve provided in the article.
Creating content like this is not an easy ride, so I hope you’ll hit that like button and drop a comment. As usual, constructive feedback is always welcome!
📰 Read me on the platform of your choice: Substack, DEV.to
Till next time! 👋