React Clean Architecture: A 3-Layer Separation Strategy (Domain, Data, Presentation)
Let's crack open Chris's project folder. Inside src/components, there are over 100 files piled up.
Some components call fetch directly, some manage state, and others just draw the UI.
"I need to fix the login logic, but where on earth is it?"
He spends the entire day just looking for the file. As the project grows, the code gets tangled like spaghetti, and fixing one feature causes an error in a completely unrelated place.
To quell this chaos, we need a rule that defines "where code should live"—in other words, an Architecture.
Today, we will learn how to reinterpret the concept of Clean Architecture, primarily used in backend development, into 3 Layers suitable for React development.
1. The 3-Layer Structure: Separation of Roles
We will divide the app into three major chunks.
The core rule of this hierarchy is "Dependencies only point downwards." The UI knows the Domain, the Domain knows the Data, but the Data layer must not know who is using it.
2. Layer 1: Data Layer
This is the deepest layer. Its only role is to communicate with external APIs and transform (Map) data into a format suitable for our app. Not a single line of UI logic should exist here.
// src/features/user/api/userApi.ts (Data Layer)
import { axiosInstance } from '@/shared/api';
import { UserDTO, User } from '../model/types';
import { userMapper } from '../lib/userMapper';
// 1. Fetch external data
export const getUser = async (id: number): Promise<User> => {
const { data } = await axiosInstance.get<UserDTO>(`/users/${id}`);
// 2. Transform into a format suitable for our app (Map)
return userMapper(data);
};3. Layer 2: Domain Layer (Business Layer)
This acts as the brain of the app. Core logic like "Fetch user info and save to state" or "Calculate cart total" lives here. In React, Custom Hooks or Zustand Stores usually fulfill this role.
// src/features/user/model/useUser.ts (Domain Layer)
import { useQuery } from '@tanstack/react-query';
import { getUser } from '../api/userApi'; // Uses Data Layer
export const useUser = (userId: number) => {
// 1. Manage data via Data Layer
const { data, isLoading, isError } = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
});
// 2. Domain Logic (e.g., Determine if admin)
const isAdmin = data?.role === 'ADMIN';
// 3. Return what the UI needs in a convenient format
return {
user: data,
isLoading,
isError,
isAdmin,
};
};4. Layer 3: Presentation Layer (UI Layer)
This is the screen the user sees. This layer must be very "Dumb."
It does not perform complex calculations or direct API calls. It simply receives data provided by the Domain Layer and draws it.
// src/features/user/ui/UserProfile.tsx (Presentation Layer)
import { useUser } from '../model/useUser'; // Uses Domain Layer
export function UserProfile({ userId }: { userId: number }) {
// Delegate logic to the Hook
const { user, isLoading, isAdmin } = useUser(userId);
if (isLoading) return <div>Loading...</div>;
return (
<div className="card">
<h1>{user?.name}</h1>
{/* Only View logic exists */}
{isAdmin && <span className="badge">Admin</span>}
</div>
);
}5. Applying to Folder Structure (A Taste of Feature Sliced Design)
We can organize these 3 layers into an actual folder structure. Grouping by Feature is excellent for maintainability.
src/
features/
user/ # Feature: User
api/ # [Data] API, DTO
userApi.ts
model/ # [Domain] Type, Hook, Store
types.ts
useUser.ts
ui/ # [Presentation] Components
UserProfile.tsx
index.ts # Export only what needs to be public (Public API)Now Chris doesn't have to wonder where to put his code.
6. Benefits of Clean Architecture
Key Takeaways
We've neatly sliced the code into layers. Now it's time to verify "if my code works correctly."
Can we test the logic even before drawing the UI? Let's build a testing strategy that targets each layer using Vitest.
Continuing in: “Testing by Layer with Vitest: What to Test and What to Give Up”