Design Converter
Education
Last updated on Mar 27, 2025
•7 mins read
Last updated on Mar 27, 2025
•7 mins read
Want to build scalable and maintainable React apps?
A clean architecture approach can help!
By organizing your application into separate layers—domain, Data, Presentation, and Infrastructure—you can create a structure that's easy to test, update, and expand.
This blog breaks down each layer, explains dependency injection, and provides practical code examples to help you implement clean architecture in your React projects.
Let’s dive in!
Clean architecture is a software design pattern that separates application concerns into distinct, loosely coupled layers. These layers form the foundation for a scalable, maintainable React application.
• Domain Layer: Handles core business logic and domain logic.
• Data Layer: Manages data access, REST API calls, and external dependencies.
• Presentation Layer: Contains UI logic, components, and state management.
• Infrastructure Layer: Supports external systems like HTTP clients and third-party libraries.
Each layer is independent, following the Dependency Inversion Principle. The separation makes testing easier, updates safer, and scaling more predictable.
The Domain Layer represents your application's heart. It encapsulates core business logic, abstracting away details from other layers.
1// domain/User.ts 2export interface User { 3 id: string; 4 name: string; 5 email: string; 6} 7 8export function validateUser(user: User): boolean { 9 return user.email.includes('@'); 10}
• Business rules like validation logic are defined here.
• Avoid direct dependencies on UI frameworks or HTTP clients.
• Use pure functions or classes to maintain clarity.
• Domain logic should not depend on React, useEffect, or any UI-related concerns.
A good practice is to group domain types and logic into respective folders by use case or feature.
Dependency Injection enables flexible, decoupled architecture.
• Instead of hardcoding dependencies, inject them via function arguments or React context.
• You can swap out the Data Layer without changing the Domain or Presentation Layers.
• Easier to write unit tests using mock data or mock services.
1// domain/usecases/fetchUsers.ts 2export function createFetchUsers(userRepo: UserRepository) { 3 return async function fetchUsers() { 4 return await userRepo.getUsers(); 5 }; 6}
With this pattern, you can inject a real or mocked UserRepository depending on the environment.
The Presentation Layer is the user-facing part of your app. It consumes domain logic and displays data to the user.
1// components/UserList.tsx 2import React from 'react'; 3import { useUsers } from '../hooks/useUsers'; 4 5export function UserList() { 6 const { users, error } = useUsers(); 7 if (error) return <div>Error loading users</div>; 8 return ( 9 <ul> 10 {users.map(user => ( 11 <li key={user.id}>{user.name}</li> 12 ))} 13 </ul> 14 ); 15}
• UI logic is kept simple and readable.
• Use React hooks to abstract state and side effects.
• Separate presentational and container components for clarity.
• Structure your components, hooks, and views in respective folders.
Frameworks like React Native benefit greatly from this modularization.
Reusable custom hooks let you encapsulate logic outside the UI layer.
1// hooks/useUsers.ts 2import { useEffect, useState } from 'react'; 3import { fetchUsers } from '../data/userService'; 4 5export function useUsers() { 6 const [users, setUsers] = useState([]); 7 const [error, setError] = useState(null); 8 9 useEffect(() => { 10 fetchUsers().then(setUsers).catch(setError); 11 }, []); 12 13 return { users, error }; 14}
• Encourages separation of concerns.
• Simplifies testing by isolating logic.
• Makes it easy to replace data sources or logic.
You can integrate libraries like React Query or RTK Query for data fetching and caching.
The Data Layer interfaces with REST APIs, databases, and any external service.
1// data/userService.ts 2import { User } from '../domain/User'; 3 4export async function fetchUsers(): Promise<User[]> { 5 const res = await fetch('/api/users'); 6 return await res.json(); 7}
• Acts as a wrapper for HTTP clients or GraphQL clients.
• Replaceable during tests with mock implementations.
• Should not contain business rules.
You can build integration tests here to verify that API responses match expectations.
Avoid magic values scattered throughout the codebase. Use a separate object to centralize them.
1// config/constants.ts 2export const API_ENDPOINTS = { 3 USERS: '/api/users', 4 POSTS: '/api/posts', 5};
• Helps in avoiding typos and maintaining consistency.
• Central place for environment-specific configs.
• Easier to update and test.
Testing is more effective when layers are decoupled.
• Unit Tests: Validate business rules in the Domain Layer.
• Integration Tests: Ensure communication between layers.
• End-to-End Tests: Verify full application behavior.
1// tests/domain/validateUser.test.ts 2import { validateUser } from '../../domain/User'; 3 4test('should validate user with email', () => { 5 expect(validateUser({ id: '1', name: 'Alice', email: 'alice@example.com' })).toBe(true); 6});
Mock data and utility functions help create robust test cases.
Even with a well-structured architecture, mistakes can creep in, leading to technical debt and poor maintainability. Here are some common pitfalls, along with better approaches to avoid them.
🚨 Bad Approach: Writing business rules directly inside UI components. This makes the code harder to test and reuse since the logic is tied to the UI. If another component needs the same logic, it has to be duplicated or refactored later.
✅ Better Approach: Keep business logic in a separate Domain Layer. UI components should only focus on displaying data and handling interactions. This separation makes the code more maintainable and reusable across different parts of the application.
🚨 Bad Approach: Fetching data inside UI components. This tightly couples the component with data-fetching logic, making it harder to test, mock, or replace the data source when needed. It also leads to unnecessary re-renders and performance issues.
✅ Better Approach: Move API calls to a dedicated Data Layer and use custom hooks or state management solutions to supply data to components. This improves modularity, makes testing easier, and allows data sources to be changed without modifying multiple components.
🚨 Bad Approach: Skipping architectural best practices to speed up development, such as directly modifying state within components or using global variables for data storage. This results in tangled dependencies and makes future changes more complex.
✅ Better Approach: Follow a structured approach from the beginning. Keep concerns separate, use dependency injection, and modularize the codebase. While it may take a bit more time initially, it prevents major refactoring later and ensures long-term maintainability.
🚨 Bad Approach: Allowing the Presentation Layer to directly access database queries or API calls. This creates rigid dependencies, making it difficult to modify or replace individual components without affecting the entire application.
✅ Better Approach: Use well-defined interfaces between layers. The UI should communicate with the Domain Layer, which, in turn, interacts with the Data Layer. This ensures flexibility, allowing changes in one layer without disrupting others.
🚨 Bad Approach: Integrating third-party libraries directly into core business logic. This creates dependencies that can break if the library is updated, discontinued, or replaced.
✅ Better Approach: Keep the Domain Layer framework-agnostic. If an external library is needed, use wrappers or adapters in the Infrastructure Layer to interact with it. This keeps the business logic stable and minimizes the impact of changes in dependencies.
Stick to clean architecture principles to avoid bad design creeping into the code base.
Mastering React Clean Architecture helps you build scalable, maintainable, and testable applications by keeping concerns separate and dependencies flexible. By structuring your app into distinct layers—Domain, Data, Presentation, and Infrastructure—you ensure better code organization, easier debugging, and smoother scaling. Following principles like Dependency Injection and separation of concerns will help maintain long-term code quality. Whether you're developing a new project or refactoring an existing one, adopting this architecture will lead to cleaner, more efficient React applications.
Tired of manually designing screens, coding on weekends, and technical debt? Let DhiWise handle it for you!
You can build an e-commerce store, healthcare app, portfolio, blogging website, social media or admin panel right away. Use our library of 40+ pre-built free templates to create your first application using DhiWise.