React applications often start with a few components, a couple of hooks, and some API calls. As features grow, however, components become tightly coupled, business logic gets scattered across files, and even small changes can introduce unexpected bugs.
React architecture patterns help developers organize applications around clear responsibilities, reusable logic, and predictable data flow. Choosing the right pattern makes it easier to test features, onboard developers, and maintain an application as its complexity increases.
In this guide, we will explore component composition, container and presentational components, custom hooks, feature-based architecture, and repository patterns. We will also build a runnable JavaScript example that demonstrates how these patterns work together without introducing unnecessary abstractions.
1. Essential React Architecture Patterns for Scalable Applications
Component Composition
Component composition is one of React's most important architectural techniques. Instead of creating large components that handle every possible variation, developers build smaller components and combine them through props and children. This approach keeps components focused and makes UI structures easier to extend without duplicating implementation details.
For example, a reusable Card component can accept children, allowing different features to provide their own content. The Card does not need to understand whether it contains a user profile, product details, or a dashboard summary. It only needs to manage the shared presentation structure.
Container and Presentational Components
The container and presentational pattern separates application behavior from visual rendering. Container components coordinate data retrieval, state, and event handlers, while presentational components receive props and render the interface. This separation is useful when a screen has complicated business logic or when the same visual component appears in multiple places.
Modern React applications do not need to enforce this separation through separate files for every component. Hooks and composition often provide a simpler implementation, but the underlying principle remains valuable: keep rendering concerns distinct from data and business rules.
Custom Hooks for Reusable Logic
Custom hooks extract stateful behavior into reusable functions whose names begin with use. A hook such as useProducts can manage loading, errors, and product data while allowing multiple components to share the same behavior. Hooks reduce duplicated logic without requiring components to inherit from a shared base class.
A good custom hook has a clear responsibility and a predictable interface. For example, useProducts might return products, loading, error, and a refresh function. Keeping the returned interface focused makes the hook easier to test and prevents unrelated features from becoming tightly coupled.
Feature-Based Architecture
Feature-based architecture organizes code by business capability rather than by technical file type alone. Instead of placing every component in one global components directory and every hook in one global hooks directory, a project can group product-related components, hooks, API functions, and business rules under a products feature.
A practical structure might look like this:
src/
app/
App.js
routes.js
features/
products/
components/
ProductList.js
hooks/
useProducts.js
services/
productService.js
utils/
productUtils.js
shared/
components/
hooks/
utils/
This organization improves discoverability and helps teams establish clear boundaries between features. Shared code should contain genuinely reusable functionality rather than becoming a dumping ground for components that happen to be used in more than one place.
Service and Repository Patterns
A service layer centralizes communication with external systems, such as REST APIs, authentication providers, and third-party services. A repository layer can provide a consistent interface for retrieving or persisting data while hiding implementation details from the rest of the application.
For example, a product service might fetch products from an HTTP endpoint, while a repository exposes methods such as getAll and getById. This separation is particularly useful when data access becomes complex or when an application needs to switch between different data sources. For a small application, a dedicated service may be sufficient; adding a repository layer is worthwhile only when it provides a meaningful benefit.
Managing State at the Right Level
State architecture is another important design decision. Local component state is appropriate for temporary UI concerns, such as whether a dropdown is open, while shared application state may be necessary for authentication, shopping carts, or complex workflows. Server state, including API responses and caching, is often better handled with a dedicated data-fetching solution than with manually duplicated component state.
Avoid moving every state variable into a global store simply because the application is growing. Start with the narrowest appropriate scope and lift state only when multiple components genuinely need to coordinate. This keeps dependencies explicit and reduces unnecessary re-renders and synchronization problems.
Putting the Patterns Together
The following JavaScript example demonstrates several architecture principles using plain JavaScript classes and functions. It separates product data access, business rules, and presentation formatting, making the example runnable directly in Node.js without installing React or additional dependencies.
In a real React application, the service and business logic could be consumed by a custom hook, and the resulting data could be passed into presentational components. The example intentionally focuses on architecture boundaries rather than JSX so that each responsibility is easy to understand and test.
/*
* React Architecture Patterns: A runnable JavaScript example.
* Run with: node architecture.js
* Demonstrates service, repository, business logic, composition,
* dependency injection, and predictable error handling.
*/
console.log("Step 1: Starting the product application");
// Simulate an external API with an asynchronous data source.
const mockApi = {
async fetchProducts() {
console.log("Fetching products from the simulated API...");
await new Promise((resolve) => setTimeout(resolve, 100));
return [
{ id: 1, name: "Keyboard", price: 2499, stock: 12 },
{ id: 2, name: "Mouse", price: 999, stock: 25 },
{ id: 3, name: "Monitor", price: 12999, stock: 0 },
{ id: 4, name: "Headphones", price: 1999, stock: 8 }
];
}
};
console.log("Step 2: Defining the repository boundary");
// The repository hides the details of retrieving product data.
class ProductRepository {
constructor(api) {
if (!api || typeof api.fetchProducts !== "function") {
throw new Error("A valid product API must be provided");
}
this.api = api;
}
async getAll() {
const products = await this.api.fetchProducts();
return products;
}
async getById(id) {
const products = await this.getAll();
return products.find((product) => product.id === id) || null;
}
}
console.log("Step 3: Defining the business service");
// Business rules live here rather than inside UI components.
class ProductService {
constructor(repository) {
this.repository = repository;
}
async getAvailableProducts() {
const products = await this.repository.getAll();
// Only products with stock can be purchased.
return products.filter((product) => product.stock > 0);
}
async getProductSummary(id) {
const product = await this.repository.getById(id);
if (!product) {
throw new Error(`Product ${id} was not found`);
}
return {
id: product.id,
name: product.name,
price: product.price,
available: product.stock > 0
};
}
}
console.log("Step 4: Defining reusable presentation helpers");
// These functions format data without knowing how it was retrieved.
function formatPrice(price) {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0
}).format(price);
}
function renderProductCard(product) {
const availability = product.stock > 0
? `In stock: ${product.stock}`
: "Out of stock";
// Return a plain object representing the presentational output.
return {
title: product.name,
price: formatPrice(product.price),
availability
};
}
function renderProductList(products) {
// Composition: a list is assembled from reusable product cards.
return products.map(renderProductCard);
}
console.log("Step 5: Composing application dependencies");
// Dependency injection makes each layer easier to replace in tests.
const repository = new ProductRepository(mockApi);
const productService = new ProductService(repository);
console.log("Step 6: Loading available products");
async function main() {
try {
// The application requests business-ready data from the service.
const availableProducts =
await productService.getAvailableProducts();
console.log("Available product count:", availableProducts.length);
// The presentation layer formats data without fetching it.
const productCards = renderProductList(availableProducts);
console.log("Step 7: Rendering the composed product list");
productCards.forEach((card, index) => {
console.log(`Product ${index + 1}:`);
console.log(" Name:", card.title);
console.log(" Price:", card.price);
console.log(" Status:", card.availability);
});
console.log("Step 8: Requesting a product summary");
const summary = await productService.getProductSummary(3);
console.log("Product summary:", summary);
console.log("Step 9: Checking missing-product error handling");
try {
await productService.getProductSummary(999);
} catch (error) {
// Handle expected domain errors at an appropriate boundary.
console.log("Handled error:", error.message);
}
console.log("Step 10: Application workflow completed");
} catch (error) {
// A top-level boundary prevents unhandled promise rejections.
console.error("Application failed:", error.message);
process.exitCode = 1;
}
}
main();
Conclusion
React architecture is not about creating the maximum number of folders, hooks, or abstraction layers. It is about establishing boundaries that make responsibilities clear, reduce duplication, and allow individual parts of the application to change independently.
Start with small components, use composition to build reusable interfaces, and extract custom hooks when stateful behavior needs to be shared. Organize larger applications around business features, centralize external communication in services, and introduce repositories or global state only when the complexity justifies them.
Most importantly, let your architecture evolve with your application. A straightforward structure that solves today's problems is usually better than an elaborate system designed for hypothetical future requirements. Which React architecture pattern has made the biggest difference in your projects?
Top comments (1)
ansh, this is a masterclass in pragmatic react architecture. the emphasis on "avoiding over-abstraction" and letting the structure evolve with the app is the exact antidote to the "folder fatigue" that plagues so many codebases.
one thing that stands out in your js example is the dependency injection between the api, repository, and service layers. by decoupling the
mockApifrom theProductRepository, you make unit testing trivial without needing heavy mocking libraries.moreover, this exact separation is the secret sauce for smoothly migrating to modern paradigms (like react server components or edge functions). because the business logic and data fetching are already isolated from the ui rendering, you can swap the presentation layer without rewriting your core domain rules.
fantastic, runnable breakdown. treating architecture as a tool for clarity, not a rigid dogma, is exactly how you build maintainable software. 🐯⚛️