Table of content
- Component Layering
- Business Logic and Reusable Services
- Testability
- SOLID Principles
- Cross-Cutting Concerns
- Directory Structure and Modularity
- Interfacing with APIs
- Evolutionary Architecture
In modern web development, scalability, maintainability and testability are essential. As React applications grow in size and complexity, applying architectural principles becomes crucial to keeping the codebase clean and manageable. This is where Clean Architecture comes into play.
Clean Architecture, a term coined by Robert C. Martin, encourages the separation of concerns and promotes the creation of systems that are easy to extend and evolve over time. Applying these principles in a React project leads to better component structure, easier testing, and more flexible business logic.
In this article, we will explore how Clean Architecture can be applied to a React project by focusing on key concepts like component layering, separation of business logic, reusable services, and the application of SOLID principles. We will also touch on important topics like testability, cross-cutting concerns, directory structure, and how interface with APIs effectively. Finally, we will discuss evolutionary architecture and how following theses practices ensures your React App can grow adapt to future changes without sacrificing code quality.
Component Layering
One of the core ideas behind Clean Architecture is the separation of concerns, and this starts with component layering. In React, components should be clearly divided between those that handle application logic (often referred to as smart or container components) and those focus on rendering UI (often called dumb or presentational components).
Smart vs Dumb Components
- Smart components are responsible for fetching data, managing state, and handling side effects such as API calls, or interacting with local storage. They act as the bridge between the business logic and the UI.
- Dumb components, on the other hand, are stateless and purely responsible for displaying the UI. They receive data through props and render it without making any assumptions about where the data comes from.
By applying this layered approach, we ensure that the components focusing on UI remain simple, reusable, and testable, while logic-heavy components are responsible for managing the application’s state and side effects.
Example:
Let's say we're building a product listing page. A smart component can handle the data fetching and state management, while dumb component renders the product list.
// ProductContainer.tsx - Smart Component
import { useFetchProducts } from './useFetchProducts';
const ProductContainer: React.FC = () => {
const { data, loading, error } = useFetchProducts();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <ProductList products={data} />;
};
// ProductList.tsx - Dumb Component
interface Product {
id: number;
name: string;
}
interface ProductListProps {
products: Product[];
}
const ProductList: React.FC<ProductListProps> = ({ products }) => (
<ul>
{products.map(product => (
<ProductItem key={product.id} product={product} />
))}
</ul>
);
// ProductItem.tsx - Dumb Component
interface ProductItemProps {
product: Product;
}
const ProductItem: React.FC<ProductItemProps> = ({ product }) => (
<li>{product.name}</li>
);
export default ProductContainer;
Benefits of Component Layering:
- Simplicity: By separating logic and UI, each component focuses on a single concern.
- Reusability: Presentational components can be reused across different parts of the application.
- Testability: Smart components can be tested for their logic, and dumb components can be tested for their rendering.
By adopting this approach, we create a clean structure in React where the UI is decoupled from the underlying business logic. This makes our code easier to maintain and extend over time.
Business Logic and Reusable Services
In Clean Architecture, business logic should be decoupled from the UI. This separation ensures that your core application logic remains reusable, testable and independent of any specific UI framework. In React, it's common to use hooks and service layers to handle business logic separately from the components.
Separation of Concerns with Hooks and Services
React components should focus on rendering the UI, while business logic should be handled by hooks and service layers. Custom hooks can encapsulate reusable logic, like API calls, state management, and data transformation, while service functions can handle external concerns such as data fetching, authentication, and more.
By isolating this logic, your components become more declarative and easier to maintain, while your services can be reused across different parts of your application or even in different projects.
Example:
Let's look at how we can structure a service for fetching products and encapsulate that logic in a custom hook.
// productService.ts - Reusable Service
export interface Product {
id: number;
name: string;
}
export const fetchProducts = async (): Promise<Product[]> => {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
};
// useFetchProducts.ts - Business Logic Encapsulated in a Custom Hook
import { useState, useEffect } from 'react';
import { Product, fetchProducts } from './productService';
export const useFetchProducts = () => {
const [data, setData] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const loadProducts = async () => {
try {
const products = await fetchProducts();
setData(products);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
loadProducts();
}, []);
return { data, loading, error };
};
Benefits of Separating Business Logic:
- Reusability: The logic for fetching products can be reused across different components or even projects.
- Testability: By isolating business logic in a custom hook or service, we can test the logic independently of the UI.
- Maintainability: Keeping components simple and focused on rendering makes them easier to reason about and maintain over time.
This structure ensures that React components remain focused on displaying the UI, while business logic lives in services and hooks that are easy to reuse and test in isolation.
Testability
One of the key principles of Clean Architecture is to make the codebase highly testable. In React applications, this can be achieved by designing components, hooks, and services in a way that allows for easy unit, integration, and end-to-end testing.
Testing Smart and Dumb Components
As we discussed in the component layering section, separating smart components (those responsible for logic) from dumb components (those responsible for UI) improves testability.
- Dumb Components can be tested with snapshot tests or unit tests to ensure that they render correctly given a set of props.
- Smart Components can be tested by mocking the business logic, ensuring that they handle different states like loading, error and success correctly.
Testing Business Logic in Isolation
By moving the business logic into services and hooks, you can make that logic easy to test in isolation. You can mock external dependencies like APIs and ensure that your logic handles different conditions such as API failures or empty data.
Example:
Let's write some unit tests for both the dumb component (ProductList) and the custom hook (useFetchProducts) using React Testing Library and Jest.
// ProductList.test.tsx - Testing Dumb Component
import { render, screen } from '@testing-library/react';
import { ProductList } from './ProductList';
const mockProducts = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' },
];
test('renders a list of products', () => {
render(<ProductList products={mockProducts} />);
expect(screen.getByText('Product 1')).toBeInTheDocument();
expect(screen.getByText('Product 2')).toBeInTheDocument();
});
// useFetchProducts.test.tsx - Testing Business Logic in the Hook using renderHook
import { renderHook, waitFor } from '@testing-library/react';
import { useFetchProducts } from './useFetchProducts';
import { fetchProducts } from './productService';
jest.mock('./productService');
const mockProducts = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' },
];
test('fetches and returns products', async () => {
(fetchProducts as jest.Mock).mockResolvedValueOnce(mockProducts);
const { result } = renderHook(() => useFetchProducts());
// Assert initial loading state
expect(result.current.loading).toBe(true);
// Await the loading state change and data
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual(mockProducts);
expect(result.current.error).toBeNull();
});
test('handles API error', async () => {
(fetchProducts as jest.Mock).mockRejectedValueOnce(new Error('API Error'));
const { result } = renderHook(() => useFetchProducts());
// Assert initial loading state
expect(result.current.loading).toBe(true);
// Await the loading state change and error
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error?.message).toBe('API Error');
expect(result.current.data).toEqual([]);
});
Benefits of Testability:
- Confidence in Code: By testing components and business logic independently, we can ensure that our application behaves as expected without relying on manual testing.
- Early Bug Detection: Automated tests help catch bugs early during development, reducing the cost and effort of fixing issues later.
- Refactor-Friendly: With a solid test suite in place, refactoring becomes much safer since you can quickly validate that changes haven’t broken any existing functionality.
Testability is not only about writing tests but about designing your React application in a way that makes writing tests easier and more efficient. This involves structuring your code to be modular, isolating concerns, and using patterns that make the logic testable without the need for complex setups.
SOLID Principles
The SOLID principles, originally defined for object-oriented programming, are also highly applicable to React applications. By following these principles, you can create components, hooks, and services that are easy to maintain, extend, and test over time.
1. Single Responsibility Principle (SRP)
Each component, hook, or service should have only one reason to change. In a React application, this translates into making components focus on rendering the UI and delegating business logic to hooks or services.
Example:
// ProductList.tsx - Focused Only on Rendering
import React from 'react';
import { Product } from './productService';
interface ProductListProps {
products: Product[];
}
export const ProductList: React.FC<ProductListProps> = ({ products }) => (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
In this example, the ProductList component is only responsible for rendering the products. The logic for fetching or managing products should reside in separate hooks or services, adhering to the Single Responsibility Principle.
2. Open/Closed Principle (OCP)
Components and services should be open for extension but closed for modification. This means you should be able to extend their functionality without modifying their existing code.
Example:
// productService.ts - Open for Extension by Adding Filters
export const fetchProductsByCategory = async (categoryId: number): Promise<Product[]> => {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
return response.json();
};
Here, we extend the fetchProducts service by creating a new function fetchProductsByCategory without modifying the original fetchProducts logic. This keeps the original service intact and easily extendable.
3. Liskov Substitution Principle (LSP)
Components, hooks, or services should be replaceable with their base or parent classes/interfaces without breaking the application. In React, this can be applied by making sure components or hooks adhere to consistent contract interfaces.
Example:
// Product.tsx - Rendering Any Kind of Product Component
interface ProductProps {
name: string;
}
export const Product: React.FC<ProductProps> = ({ name }) => (
<div>{name}</div>
);
// DigitalProduct.tsx - A Specialized Product
interface DigitalProductProps extends ProductProps {
fileSize: string;
}
export const DigitalProduct: React.FC<DigitalProductProps> = ({ name, fileSize }) => (
<div>
{name} - {fileSize}
</div>
);
Here, DigitalProduct can replace Product in the UI without breaking the application because it adheres to the same interface as Product (inheriting from ProductProps). This ensures that it complies with the Liskov Substitution Principle.
4. Interface Segregation Principle (ISP)
Instead of forcing a large, complex interface on components or hooks, break down interfaces into smaller, more specific ones. This avoids components having to implement or handle unnecessary properties.
Example:
// Separate Specific Interfaces
interface BasicProduct {
id: number;
name: string;
}
interface DigitalProduct extends BasicProduct {
fileSize: string;
}
interface PhysicalProduct extends BasicProduct {
weight: string;
}
// ProductDetails.tsx - Only Handling What’s Necessary
const ProductDetails: React.FC<BasicProduct> = ({ id, name }) => (
<div>
Product ID: {id}, Name: {name}
</div>
);
In this example, we’ve broken down the product interfaces to ensure components only deal with the specific data they need, adhering to the Interface Segregation Principle.
5. Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. In React, this can be applied by injecting dependencies such as API services or state management into components via hooks or contexts, rather than hard-coding them.
Example:
// ProductContext.tsx - Providing Dependencies via Context
import React, { createContext, useContext } from 'react';
import { Product, fetchProducts } from './productService';
interface ProductContextType {
products: Product[];
fetchAll: () => void;
}
const ProductContext = createContext<ProductContextType | undefined>(undefined);
export const ProductProvider: React.FC = ({ children }) => {
const [products, setProducts] = React.useState<Product[]>([]);
const fetchAll = async () => {
const data = await fetchProducts();
setProducts(data);
};
return (
<ProductContext.Provider value={{ products, fetchAll }}>
{children}
</ProductContext.Provider>
);
};
// useProduct.ts - Hook for Accessing Context
export const useProduct = () => {
const context = useContext(ProductContext);
if (!context) {
throw new Error('useProduct must be used within a ProductProvider');
}
return context;
};
In this example, ProductProvider provides the product data and fetching logic through a context. Components that consume this logic do not depend directly on the fetchProducts service but on the abstraction provided by the context, adhering to the Dependency Inversion Principle.
Conclusion on SOLID in React:
Applying SOLID principles in React leads to more maintainable and scalable applications. By following SRP, you can keep components focused and simple. OCP ensures your components and services are easily extendable. LSP and ISP allow for flexibility and better abstractions, while DIP promotes loose coupling and better separation of concerns.
Cross-Cutting Concerns
Cross-cutting concerns in software architecture refer to functionalities that affect multiple components in your system. These can include logging, error handling, caching, security, and more. In a React application, it’s essential to manage cross-cutting concerns in a way that promotes reusability, separation of concerns, and maintainability.
In React, cross-cutting concerns can be implemented using techniques like Context API, higher-order components (HOCs), render props, or custom hooks. Each of these patterns allows us to handle cross-cutting logic without scattering the same code across multiple components.
Example 1: Handling Cross-Cutting Concerns with Context API (Error Handling)
Let’s say you need to manage error handling consistently across your application. A good approach is to centralize the error handling using React’s Context API.
ErrorContext.tsx - Centralized Error Management
import React, { createContext, useState, useContext, ReactNode } from 'react';
interface ErrorContextType {
error: string | null;
setError: (message: string | null) => void;
}
const ErrorContext = createContext<ErrorContextType | undefined>(undefined);
export const ErrorProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [error, setError] = useState<string | null>(null);
return (
<ErrorContext.Provider value={{ error, setError }}>
{children}
</ErrorContext.Provider>
);
};
export const useError = () => {
const context = useContext(ErrorContext);
if (!context) {
throw new Error('useError must be used within an ErrorProvider');
}
return context;
};
Example of Usage in a Component:
// ProductComponent.tsx - Using Centralized Error Handling
import React, { useEffect } from 'react';
import { useFetchProducts } from './useFetchProducts';
import { useError } from './ErrorContext';
export const ProductComponent: React.FC = () => {
const { data, error: fetchError } = useFetchProducts();
const { setError } = useError();
useEffect(() => {
if (fetchError) {
setError(fetchError.message);
}
}, [fetchError, setError]);
return (
<div>
{data.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
In this example:
- We create an ErrorContext that provides a centralized place to manage application-wide error states.
- The ProductComponent can leverage this context for setting errors when the useFetchProducts hook encounters an error.
Example 2: Using Custom Hooks for Cross-Cutting Concerns (Authentication)
Another common cross-cutting concern is managing authentication across your app. A custom hook can be used to handle user authentication and protect routes.
useAuth.tsx - Custom Hook for Authentication
import { useState } from 'react';
interface User {
id: string;
name: string;
}
export const useAuth = () => {
const [user, setUser] = useState<User | null>(null);
const login = (userId: string, userName: string) => {
// Mock login logic
setUser({ id: userId, name: userName });
};
const logout = () => {
// Mock logout logic
setUser(null);
};
return { user, login, logout };
};
AuthenticatedComponent.tsx - Protecting Routes
import React from 'react';
import { useAuth } from './useAuth';
export const AuthenticatedComponent: React.FC = () => {
const { user, logout } = useAuth();
if (!user) {
return <div>Please log in to access this page.</div>;
}
return (
<div>
Welcome, {user.name}! <button onClick={logout}>Logout</button>
</div>
);
};
In this case:
- The useAuth hook handles the login/logout logic and provides a way to check the current user.
- The AuthenticatedComponent can use this hook to check if a user is authenticated and render content accordingly.
Cross-Cutting Concerns Recap
Cross-cutting concerns in React, such as error handling, authentication, logging, or caching, can be effectively handled using tools like Context API and custom hooks. By doing this, we maintain separation of concerns and promote reusable logic, reducing the need to duplicate these functionalities across multiple components.
Directory Structure and Modularity
A well-organized directory structure is essential for managing complexity in a React application, especially as it scales. By focusing on modularity, separation of concerns, and grouping related files logically, you can make your codebase easier to maintain, extend, and understand. There’s no one-size-fits-all solution, but certain best practices can help.
Key Principles for Structuring a React App
- Feature-Based Structure: Organizing the app based on features, rather than types of files (components, hooks, services, etc.), helps to isolate functionalities and keeps all relevant code in one place.
- Separation of Concerns: Keep concerns like UI components, business logic, and data fetching separated into their respective modules.
- Scalability: Organize your app to handle future growth, ensuring that adding new features or functionality doesn’t lead to clutter or complexity.
- Reusability: Group reusable logic and components in a way that encourages reusability across the app.
Let’s break down an example directory structure for a feature-based React application.
Example: Directory Structure
src/
│
├── components/
│ ├── common/ # Shared UI components (buttons, inputs, etc.)
│ ├── layout/ # Layout components (Header, Footer, etc.)
│ └── ProductList.tsx # UI component specific to a feature
│
├── features/
│ ├── products/ # Feature-based directory for "products"
│ │ ├── components/ # Components specific to this feature
│ │ ├── hooks/ # Custom hooks related to products
│ │ ├── services/ # API services for product data fetching
│ │ └── ProductPage.tsx # Page component for product feature
│ └── authentication/ # Feature-based directory for "authentication"
│ ├── components/
│ ├── hooks/
│ └── services/
│
├── hooks/ # Global custom hooks
│ └── useAuth.tsx # Example of a reusable hook
│
├── services/ # Shared services like API or utility services
│ └── api.ts # Generic API service with fetch logic
│
├── context/ # Global contexts
│ └── ErrorContext.tsx # Centralized error handling
│
└── App.tsx # Root component
Directory Breakdown
-
components/
: Contains reusable and global components that are not tied to any specific feature. This includes common components like buttons, inputs, layout components (e.g., headers and footers), and any shared UI. -
features/
: The core of the structure where each feature (e.g., products, authentication) has its own directory. Each feature contains its own components, hooks, services, and pages. This makes it easier to scale and modify individual features independently. -
hooks/
: A folder for global custom hooks that can be shared across the application. Hooks that are feature-specific would go inside the feature directories. -
services/
: Contains shared services like API logic, utility functions, or any shared data-fetching logic. Feature-specific services should be organized within their respective feature directories. -
context/
: A centralized place for React Contexts that manage global application state (e.g., error handling, authentication state, or theme).
Why Feature-Based Structure?
- Modularity: By organizing your app around features, you keep everything related to a feature together. This makes it easy to reason about where to find and update code.
- Encapsulation: Each feature is encapsulated within its own folder, promoting separation of concerns and reducing the likelihood of unintended side effects.
- Scalability: As the app grows, you can add new features without modifying or cluttering existing ones.
Example: Product Feature Structure
To demonstrate the principle, here’s an example of how you could organize a feature like products.
src/
└── features/
└── products/
├── components/
│ └── ProductList.tsx # List of products UI
├── hooks/
│ └── useFetchProducts.tsx # Custom hook for product fetching
├── services/
│ └── productService.ts # Product-related API services
└── ProductPage.tsx # Main page component for products
Each feature contains:
- Components: UI components related to the feature.
- Hooks: Custom hooks that encapsulate logic related to the feature (e.g., fetching product data).
- Services: Services for interacting with external APIs or managing business logic related to products.
- Page Component: The main page for this feature, rendering the overall layout and structure of the feature.
Evolutionary Directory Structure
As your application evolves, so should your directory structure. An evolutionary architecture approach suggests that the structure you start with should be flexible enough to adapt to changes in requirements, features, and complexity.
- Start Simple: If you’re building a small project, avoid over-structuring the app from the start. Begin with a simpler structure, but make sure it’s easy to refactor as the project scales.
- Modular Growth: As new features are added, group related components, hooks, and services together to promote feature-based modularity.
- Refactor Early and Often: As soon as you notice a pattern (e.g., multiple features needing similar logic), refactor common logic into shared modules (such as global hooks or services). Don’t hesitate to adjust the structure when it starts becoming harder to navigate or maintain.
Recap on Directory Structure and Modularity
A modular and well-structured directory keeps your React codebase maintainable and scalable as the application grows. By using a feature-based directory structure, you can encapsulate logic, promote separation of concerns, and ensure that individual features can evolve independently.
Interfacing with APIs
Interfacing with APIs is a fundamental aspect of modern web applications, especially in React. As applications become more complex and rely on external data, it’s crucial to manage API interactions effectively. This includes organizing API calls, handling responses, and managing error states in a way that keeps the application responsive and user-friendly.
Key Considerations for API Interfacing
- Separation of Concerns: API logic should be separated from UI components. This makes it easier to manage, test, and reuse API calls without intertwining them with the presentation layer.
- Consistent Error Handling: Ensure that errors from API calls are handled uniformly across the application to enhance user experience and debugging.
- Data Transformation: Often, data received from APIs may need to be transformed into a format that your application can work with efficiently. Managing this transformation in a dedicated layer helps maintain clean code.
- Caching and Optimizations: Depending on the app’s requirements, implementing caching strategies can significantly improve performance, especially for frequently accessed data.
Example: Structuring API Calls
We can manage API calls in a dedicated service layer. Let’s create a simple example for interfacing with a products API.
Product Service (productService.ts)
// src/features/products/services/productService.ts
const API_URL = 'https://api.example.com/products';
export const fetchProducts = async () => {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const data = await response.json();
return data; // Transform data if necessary
};
In this service:
- We define a fetchProducts function that handles the API call to fetch product data.
- The function throws an error if the response is not okay, promoting consistent error handling.
Using the Product Service in a Custom Hook (useFetchProducts.ts)
Now, let’s create a custom hook that uses the product service.
// src/features/products/hooks/useFetchProducts.ts
import { useEffect, useState } from 'react';
import { fetchProducts } from '../services/productService';
export const useFetchProducts = () => {
const [data, setData] = useState([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProducts = async () => {
try {
const products = await fetchProducts();
setData(products);
} catch (error: any) {
setError(error.message);
} finally {
setLoading(false);
}
};
loadProducts();
}, []);
return { data, error, loading };
};
In this hook:
- We manage the loading state and error state to give feedback to the user.
- The fetchProducts service is called within a useEffect hook to fetch data when the component mounts.
Using the Custom Hook in a Component (ProductComponent.tsx)
Finally, we can use the custom hook in our component.
// src/features/products/components/ProductComponent.tsx
import React from 'react';
import { useFetchProducts } from '../hooks/useFetchProducts';
export const ProductComponent: React.FC = () => {
const { data, error, loading } = useFetchProducts();
if (loading) {
return <div>Loading products...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
{data.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
};
Best Practices for API Interfacing
- Keep Services Isolated: By isolating API services from components, you promote reusability and maintainability.
- Error Handling: Always handle errors gracefully and inform users about issues without crashing the app.
- Transform Data: If you need to manipulate or transform the API data, consider doing so within the service or a separate function, maintaining separation of concerns.
- Use HTTP Clients: Consider using libraries like Axios or Fetch API to simplify HTTP requests and improve error handling.
- Caching Strategies: Implement caching mechanisms (like React Query) if your application fetches data frequently to enhance performance.
Recap on Interfacing with APIs
Organizing API interactions is crucial for building scalable and maintainable React applications. By structuring your API logic in a dedicated service layer, you promote separation of concerns and enhance the overall user experience through consistent error handling and loading states.
Conclusion
Clean architecture in React promotes maintainability, scalability, and adaptability. By focusing on component layering, reusable services, testability, SOLID principles, modularity, API interfacing, and evolutionary architecture, you can build robust applications that stand the test of time.
This article specifically addresses clean architecture within the context of React, providing practical examples and guidelines tailored to the librairie. However, I highly recommend reading Robert C. Martin’s book on clean architecture, as it delves deeper into the subject beyond specific libraries and frameworks. Martin’s insights are not only informative but also truly passionate, offering a broader understanding of architectural principles that can enhance your overall approach to software development.
By following these guidelines, you can ensure that your application remains manageable and easy to adapt as your project grows and changes.
Top comments (0)