Skip to main content

Building Scalable React Apps: Full Architecture Walk

Building a scalable React app requires putting all pieces together: layered architecture, clean domain logic, use cases, repositories, and tests. This article walks through a complete case study: a simplified e-commerce app (product search, cart, checkout) architected for testability, maintainability, and growth. You will see how domain logic, use cases, repositories, and React components form a cohesive system, why certain boundaries exist, and how to extend the design without breaking existing code.

This is the synthesis of everything in this series. I built this example from a real production codebase, keeping only the essential patterns so you can see the principles in action without getting lost in details. By the end, you will have a mental model for architecting your own applications.

Project Structure

src/
├── domains/
│ ├── product/
│ │ ├── core/
│ │ │ ├── Product.js
│ │ │ └── validateProductQuery.js
│ │ ├── usecases/
│ │ │ ├── SearchProductsUseCase.js
│ │ │ └── GetProductByIdUseCase.js
│ │ ├── adapters/
│ │ │ └── ProductRepository.js
│ │ ├── components/
│ │ │ ├── ProductSearch.jsx
│ │ │ └── ProductCard.jsx
│ │ └── index.js
│ └── cart/
│ ├── core/
│ │ ├── Cart.js
│ │ ├── CartItem.js
│ │ └── calculateTotal.js
│ ├── usecases/
│ │ ├── AddToCartUseCase.js
│ │ └── CheckoutUseCase.js
│ ├── adapters/
│ │ └── CartRepository.js
│ ├── components/
│ │ ├── CartSummary.jsx
│ │ └── CheckoutForm.jsx
│ └── index.js
├── shared/
│ ├── hooks/
│ │ ├── useAsync.js
│ │ └── useForm.js
│ ├── components/
│ │ ├── Button.jsx
│ │ ├── Modal.jsx
│ │ └── LoadingSpinner.jsx
│ └── context/
│ ├── RepositoriesContext.js
│ └── RepositoriesProvider.jsx
└── App.jsx

Domain Layer: Product

Product entity and validation (core/Product.js):

export class Product {
constructor(id, name, price, description, inStock) {
if (price <= 0) throw new Error('Price must be positive');
this.id = id;
this.name = name;
this.price = price;
this.description = description;
this.inStock = inStock;
}

static fromJSON(obj) {
return new Product(obj.id, obj.name, obj.price, obj.description, obj.inStock);
}
}

export function validateProductQuery(query) {
if (!query || query.trim().length === 0) {
throw new Error('Query cannot be empty');
}
if (query.length > 100) {
throw new Error('Query too long');
}
}

Cart entity (core/Cart.js):

export class CartItem {
constructor(product, quantity) {
if (quantity <= 0) throw new Error('Quantity must be positive');
this.product = product;
this.quantity = quantity;
}

getTotal() {
return this.product.price * this.quantity;
}
}

export function calculateCartTotal(items, taxRate = 0.1) {
const subtotal = items.reduce((sum, item) => sum + item.getTotal(), 0);
const tax = subtotal * taxRate;
return subtotal + tax;
}

Tests for domain logic run in milliseconds:

describe('CartItem', () => {
it('calculates item total', () => {
const product = new Product(1, 'Laptop', 1000, '', true);
const item = new CartItem(product, 3);
expect(item.getTotal()).toBe(3000);
});
});

Application Layer: Use Cases

SearchProductsUseCase:

export class SearchProductsUseCase {
constructor(productRepository) {
this.productRepository = productRepository;
}

async execute(query) {
validateProductQuery(query);
return this.productRepository.search(query);
}
}

CheckoutUseCase:

export class CheckoutUseCase {
constructor(cartRepository, orderRepository) {
this.cartRepository = cartRepository;
this.orderRepository = orderRepository;
}

async execute(cartId, userId) {
// Fetch cart
const cart = await this.cartRepository.getById(cartId);
if (!cart || !cart.items.length) {
throw new Error('Cart is empty');
}

// Create order
const order = {
userId,
items: cart.items,
total: calculateCartTotal(cart.items),
createdAt: new Date(),
};

// Persist order
const savedOrder = await this.orderRepository.create(order);

// Clear cart
await this.cartRepository.clear(cartId);

return savedOrder;
}
}

Test with mocks:

describe('CheckoutUseCase', () => {
it('creates order and clears cart', async () => {
const mockCartRepo = {
getById: jest.fn().mockResolvedValue({
items: [
new CartItem(new Product(1, 'Widget', 10, '', true), 2),
],
}),
clear: jest.fn(),
};
const mockOrderRepo = {
create: jest.fn().mockResolvedValue({ id: 'order-1' }),
};

const useCase = new CheckoutUseCase(mockCartRepo, mockOrderRepo);
const order = await useCase.execute('cart-1', 'user-1');

expect(mockOrderRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'user-1' })
);
expect(mockCartRepo.clear).toHaveBeenCalledWith('cart-1');
});
});

Data Layer: Repositories

ProductRepository (HTTP implementation):

export class ProductRepository {
constructor(baseURL = '/api') {
this.baseURL = baseURL;
}

async search(query) {
const response = await fetch(
`${this.baseURL}/products/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
return data.map(obj => Product.fromJSON(obj));
}

async getById(id) {
const response = await fetch(`${this.baseURL}/products/${id}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Fetch failed');
const obj = await response.json();
return Product.fromJSON(obj);
}
}

CartRepository (localStorage implementation):

export class CartRepository {
constructor(storageKey = 'cart') {
this.storageKey = storageKey;
}

async getById(id) {
const data = JSON.parse(localStorage.getItem(this.storageKey) || '{}');
return data[id] || null;
}

async addItem(cartId, product, quantity) {
const data = JSON.parse(localStorage.getItem(this.storageKey) || '{}');
const cart = data[cartId] || { items: [] };

const existing = cart.items.find(item => item.product.id === product.id);
if (existing) {
existing.quantity += quantity;
} else {
cart.items.push(new CartItem(product, quantity));
}

data[cartId] = cart;
localStorage.setItem(this.storageKey, JSON.stringify(data));
return cart;
}

async clear(cartId) {
const data = JSON.parse(localStorage.getItem(this.storageKey) || '{}');
delete data[cartId];
localStorage.setItem(this.storageKey, JSON.stringify(data));
}
}

Presentation Layer: Components

ProductSearch component:

import { useState } from 'react';
import { SearchProductsUseCase } from '../usecases/SearchProductsUseCase';
import { useRepositories } from '../../shared/context/RepositoriesContext';

export function ProductSearch({ onSelectProduct }) {
const { productRepository } = useRepositories();
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const handleSearch = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);

try {
const useCase = new SearchProductsUseCase(productRepository);
const products = await useCase.execute(query);
setResults(products);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

return (
<div>
<form onSubmit={handleSearch}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<button disabled={loading}>{loading ? 'Searching...' : 'Search'}</button>
</form>

{error && <p style={{color: 'red'}}>{error}</p>}

<div>
{results.map((product) => (
<button
key={product.id}
onClick={() => onSelectProduct(product)}
>
{product.name} - ${product.price}
</button>
))}
</div>
</div>
);
}

CheckoutForm component:

import { useState } from 'react';
import { CheckoutUseCase } from '../usecases/CheckoutUseCase';
import { useRepositories } from '../../shared/context/RepositoriesContext';

export function CheckoutForm({ cartId, userId, onSuccess }) {
const { cartRepository, orderRepository } = useRepositories();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const handleCheckout = async () => {
setLoading(true);
setError(null);

try {
const useCase = new CheckoutUseCase(cartRepository, orderRepository);
const order = await useCase.execute(cartId, userId);
onSuccess(order);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

return (
<div>
{error && <p style={{color: 'red'}}>{error}</p>}
<button onClick={handleCheckout} disabled={loading}>
{loading ? 'Processing...' : 'Complete Order'}
</button>
</div>
);
}

Dependency Injection: RepositoriesProvider

Provide repositories to all components via Context:

import { createContext, useContext } from 'react';
import { ProductRepository } from '../domains/product/adapters/ProductRepository';
import { CartRepository } from '../domains/cart/adapters/CartRepository';
import { OrderRepository } from '../domains/order/adapters/OrderRepository';

const RepositoriesContext = createContext(null);

export function RepositoriesProvider({ children }) {
const repositories = {
productRepository: new ProductRepository('/api'),
cartRepository: new CartRepository(),
orderRepository: new OrderRepository('/api/orders'),
};

return (
<RepositoriesContext.Provider value={repositories}>
{children}
</RepositoriesContext.Provider>
);
}

export function useRepositories() {
const context = useContext(RepositoriesContext);
if (!context) {
throw new Error('useRepositories must be within RepositoriesProvider');
}
return context;
}

In App.jsx:

import { RepositoriesProvider } from './shared/context/RepositoriesProvider';
import { ProductSearch } from './domains/product/components/ProductSearch';
import { CheckoutForm } from './domains/cart/components/CheckoutForm';

export function App() {
return (
<RepositoriesProvider>
<div>
<h1>E-Commerce Store</h1>
<ProductSearch />
<CheckoutForm cartId="main" userId="user-1" />
</div>
</RepositoriesProvider>
);
}

Testing the Full Stack

Unit test: domain logic

describe('calculateCartTotal', () => {
it('includes tax in total', () => {
const items = [new CartItem(new Product(1, 'A', 100, '', true), 1)];
expect(calculateCartTotal(items, 0.1)).toBe(110);
});
});

Integration test: use case

describe('CheckoutUseCase', () => {
it('creates order from cart items', async () => {
const mockCartRepo = { getById: jest.fn().mockResolvedValue({ items: [] }) };
const mockOrderRepo = { create: jest.fn().mockResolvedValue({ id: '1' }) };
const useCase = new CheckoutUseCase(mockCartRepo, mockOrderRepo);
await useCase.execute('cart-1', 'user-1');
expect(mockOrderRepo.create).toHaveBeenCalled();
});
});

Component test: React interaction

import { render, screen, fireEvent } from '@testing-library/react';
import { CheckoutForm } from './CheckoutForm';
import { RepositoriesContext } from '../../shared/context/RepositoriesContext';

it('displays error if checkout fails', async () => {
const mockRepositories = {
cartRepository: {
getById: jest.fn().mockRejectedValue(new Error('Cart not found')),
},
};

render(
<RepositoriesContext.Provider value={mockRepositories}>
<CheckoutForm cartId="1" userId="user-1" onSuccess={() => {}} />
</RepositoriesContext.Provider>
);

fireEvent.click(screen.getByText('Complete Order'));
expect(await screen.findByText('Cart not found')).toBeInTheDocument();
});

Growth Path

As the app grows, the architecture remains stable:

  1. Add a new feature (reviews): Create domains/review/ with core, usecases, adapters, and components. No changes to existing code.
  2. Change the product API: Edit ProductRepository only. Components and use cases are unaffected.
  3. Add a new adapter (GraphQL): Create ProductGraphQLRepository that implements the same interface. Swap it in the provider.
  4. Add real-time cart sync: Wrap CartRepository in a WebSocket adapter. Components are unaffected.

Key Takeaways

  • The four layers (presentation, application, domain, data) form a complete, testable architecture.
  • Domain logic is pure and tested directly; use cases orchestrate; repositories abstract data.
  • React components are thin adapters that consume use cases and render UI.
  • Dependency injection via Context allows swapping implementations without code changes.
  • The architecture scales: add features, swap implementations, refactor safely.

Frequently Asked Questions

How many repositories should my app have?

One per domain entity or concept: UserRepository, ProductRepository, CartRepository. Avoid a single generic DataRepository.

Should I use an ORM or query builder?

For small apps, no. For large backend-heavy apps, yes (e.g., TypeORM, Prisma for Node.js backends). In React, repositories typically wrap HTTP clients or storage APIs, which are simpler.

How do I handle complex queries (filtering, pagination, sorting)?

Pass these as repository methods: productRepository.search(query, filters, { page, pageSize }). The repository handles transformation; the use case just calls it.

What if I need to share state between components?

Use Context to provide shared data (logged-in user, theme). For other data, use local state in a parent component and props. Avoid overloading Context with transient UI state.

Is this architecture overkill for a simple app?

For a simple app (5–10 components, minimal business logic), layering may feel heavy. Start simple, add structure as you grow. The patterns here prevent problems that only appear at scale.

Further Reading