Designing Large React Applications: Architecture at Scale
As a React application scales, the main challenge shifts from "how to write code" to "how to organize code so that twenty developers can work in the same repository without stepping on each other's toes."
Without a deliberate architecture, projects reach a point where:
- A change to a component in one corner of the app breaks a feature in another.
- Refactoring a single API call requires changing thirty files.
- Developers spend hours resolving merge conflicts due to bloated global files.
This guide provides an opinionated blueprint for structuring and scaling React applications using TypeScript and modern development patterns.
1. Introduction: The Reality of "Large" Applications
What "Large" Actually Means
In frontend engineering, a "large" application is defined by three vectors:
- Team Size: More than 10 developers writing code concurrently on the same repository.
- Codebase Size: Over 100,000 lines of code (LOC), hundreds of files, and multiple third-party integrations.
- Longevity: A project intended to live for 3 to 5+ years, requiring continuous maintenance and onboarding of new developers.
Why Structure Decisions Compound
In the first few months of a project, any file structure works. The codebase is small enough to fit in a single developer's head. However, structural flaws act as debt with compound interest.
If it takes 2 minutes of search time to locate a file in a messy structure, a team of 15 developers making 10 changes a day wastes over 60 hours a year just navigation-searching. More critically, high coupling leads to spaghetti dependencies—where a simple button refactor can ripple through authentication, dashboards, and reporting modules.
Prerequisites & Assumptions
This guide assumes you are building a modern single-page application (SPA) or hybrid Next.js/Vite project utilizing:
- React (v18 or v19)
- TypeScript (Strict mode enabled)
- Modern Bundlers (Vite, Webpack, or Turbopack)
- Client-side State Management
2. Folder Structure: The Feature-Based Blueprint
The Problem: Layer-Based Organization
Most junior projects start with a layer-based structure (also called "technical-type organization"):
src/
components/
Button.tsx
ProfileCard.tsx
hooks/
useAuth.ts
useProjects.ts
pages/
Dashboard.tsx
Settings.tsx
services/
api.tsWhy this breaks at scale: When you build a feature like "User Comments," you have to edit files in components/, hooks/, pages/, and services/. This context-switching is cognitively expensive. When multiple developers work on different features, they end up editing the same shared folders, resulting in constant merge conflicts.
The Solution: Feature-Based Structure (Recommended)
Instead of grouping by technical type, we group by business domain. A feature module is a self-contained unit of functionality.
Here is the concrete, copy-pasteable directory tree for a large React application:
src/
├── assets/ # Static assets (images, global fonts)
├── components/ # Core design system components (buttons, modals, tooltips)
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── index.ts
│ └── Input/
├── core/ # Global singletons & configurations
│ ├── config.ts # Typed environment variables
│ └── telemetry.ts # Logging and analytics setup
├── features/ # Self-contained domain-driven feature modules
│ ├── auth/ # Authentication domain
│ │ ├── api/ # API requests for login/logout/signup
│ │ ├── components/ # Feature-specific UI (LoginForm)
│ │ ├── hooks/ # Local authentication hooks
│ │ ├── types/ # Domain TS interfaces (User, Session)
│ │ └── index.ts # Public API entry point
│ ├── project-board/ # Project management domain
│ └── billing/ # Billing and subscription domain
├── layouts/ # Shared shell layouts (SidebarLayout, AuthLayout)
├── lib/ # Third-party SDK wrappers & configurations
│ ├── api-client.ts # Axios / Fetch client singleton
│ ├── query-client.ts # TanStack Query client setup
│ └── logger.ts # Local logging utility wrapper
├── routes/ # Routing configuration and lazy routing setup
├── store/ # Global cross-cutting state (Zustand, Redux)
├── types/ # Shared fallback TypeScript types
└── utils/ # Pure helper functions (formatters, validation helpers)3. Feature Modules: Isolation and Boundaries
The Problem: Circular and Spaghetti Imports
If features/project-board imports components directly from features/billing, and features/billing imports types from features/project-board, you create a circular dependency loop. This slows down the bundler, breaks hot module reloading (HMR), and prevents you from splitting code effectively.
The Solution: Explicit Public API Boundaries
Every feature folder must have an index.ts file acting as a gatekeeper (a barrel file). Only items exported from index.ts can be used by other parts of the application. Everything else inside the feature directory is considered private implementation detail.
Feature Directory Layout Example (src/features/project-board/):
project-board/
├── api/
│ └── getProjects.ts
├── components/
│ ├── Board.tsx
│ └── ProjectCard.tsx
├── types/
│ └── index.ts
├── index.ts # Public API boundaryThe Gatekeeper file (src/features/project-board/index.ts):
// Export only components that other features or pages need
export { Board } from "./components/Board";
// Export types that need to be referenced globally
export type { Project, ProjectStatus } from "./types";
// Keep internals like ProjectCard or getProjects private to the featureEnforcing Boundaries with ESLint:
To prevent developers from bypassing the index.ts file and importing deep files, add this ESLint configuration (using eslint-plugin-import):
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["@/features/*/*"],
"message": "Importing feature internals directly is forbidden. Use the public API exported in '@/features/*' instead."
}
]
}
]
}
}4. Component Design: Composition over Configuration
The Problem: Prop-Heavy "God" Components
In large codebases, developers often build massive components that try to cover every edge case by piling on conditional props:
// ❌ BAD: A prop-heavy component that is difficult to extend
<Card
title="Project Card"
showAvatar={true}
onButtonClick={handleEdit}
buttonText="Edit"
variant="compact"
isLoading={false}
hasBorder={true}
/>If a new screen needs a card with an icon instead of an avatar, you have to add showIcon and iconType props, bloating the component code and making it fragile.
The Solution: Component Composition
Break components down into small, composable subcomponents, utilizing React's children pattern.
Example: Designing a Composable Card Component
// src/components/Card/Card.tsx
import React, { ReactNode } from "react";
interface CardProps {
children: ReactNode;
className?: string;
}
export const Card = ({ children, className = "" }: CardProps) => {
return (
<div className={`rounded-xl border border-card-border bg-card-bg p-5 shadow-xs ${className}`}>
{children}
</div>
);
};
// Sub-components
Card.Header = function CardHeader({ children }: { children: ReactNode }) {
return <div className="border-b border-card-border/60 pb-3 mb-4">{children}</div>;
};
Card.Body = function CardBody({ children }: { children: ReactNode }) {
return <div className="text-sm text-text-muted leading-relaxed">{children}</div>;
};
Card.Footer = function CardFooter({ children }: { children: ReactNode }) {
return <div className="mt-4 flex items-center justify-end gap-2">{children}</div>;
};Usage in a Feature:
// src/features/project-board/components/ProjectCard.tsx
import { Card } from "@/components/Card/Card";
import { Button } from "@/components/Button/Button";
export const ProjectCard = ({ name, description }: { name: string; description: string }) => {
return (
<Card>
<Card.Header>
<h3 className="font-bold text-foreground">{name}</h3>
</Card.Header>
<Card.Body>
<p>{description}</p>
</Card.Body>
<Card.Footer>
<Button size="sm">Open Project</Button>
</Card.Footer>
</Card>
);
};5. The API Layer: Decoupling Data Fetching
The Problem: Fetch Calls Scattered in Components
Fetching data inside components using useEffect creates spaghetti code:
// ❌ BAD: Mixing API logistics, state, and UI rendering
useEffect(() => {
setLoading(true);
fetch(`/api/projects`, { headers: { Authorization: `Bearer ${token}` }})
.then(res => res.json())
.then(data => {
setProjects(data);
setLoading(false);
})
.catch(err => setError(err));
}, []);This is impossible to unit test without mocking the global fetch API, lacks request deduplication, and duplicates endpoint URLs across features.
The Solution: A Strongly-Typed Centralized Client + Query Hooks
Decouple API integration into three distinct layers:
- API Client Setup: A unified HTTP instance (Axios or Fetch client).
- API Endpoint Functions: Independent, typed services.
- Data Hooks: TanStack Query (React Query) wrapper hooks.
Step 1: Centralized Client (src/lib/api-client.ts)
import axios from "axios";
import { config } from "@/core/config";
export const apiClient = axios.create({
baseURL: config.apiUrl,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Attach bearer token automatically on request
apiClient.interceptors.request.use((req) => {
const token = localStorage.getItem("token");
if (token && req.headers) {
req.headers.Authorization = `Bearer ${token}`;
}
return req;
});Step 2: Typed Endpoint Definition (src/features/project-board/api/getProjects.ts)
import { apiClient } from "@/lib/api-client";
import { Project } from "../types";
export interface GetProjectsParams {
status?: "active" | "archived";
}
export const getProjects = async (params?: GetProjectsParams): Promise<Project[]> => {
const response = await apiClient.get<Project[]>("/projects", { params });
return response.data;
};Step 3: React Query Hook (src/features/project-board/hooks/useProjects.ts)
import { useQuery } from "@tanstack/react-query";
import { getProjects, GetProjectsParams } from "../api/getProjects";
export const useProjects = (params?: GetProjectsParams) => {
return useQuery({
queryKey: ["projects", params],
queryFn: () => getProjects(params),
staleTime: 5 * 60 * 1000, // 5 minutes cache lifetime
});
};6. State Management Architecture: The 4 Categories of State
To prevent global state bloat, categorize state into one of four buckets:
| State Type | Description | Recommendation |
|---|---|---|
| URL State | Slugs, search filters, modal IDs stored in the query parameter. | react-router / Next.js router |
| Server State | Cached API payloads, loading indicators, request statuses. | TanStack Query (React Query) |
| Local UI State | Dropdowns, toggles, form entries localized to a component. | Standard useState / useReducer |
| Global UI State | App-wide states like authentication, themes, and sidebar toggles. | Zustand |
The Problem with Context for High-Frequency State
React Context is designed for low-frequency updates (e.g., locale, theme). If you store frequently updated state in Context, every component consuming that context will re-render whenever any value inside changes.
The Solution: Zustand for Global UI State
Zustand is a lightweight, selector-based state manager that avoids unnecessary re-renders.
Example: Setting up a global store (src/store/uiStore.ts)
import { create } from "zustand";
interface UIState {
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebar: (open: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebar: (open) => set({ sidebarOpen: open }),
}));Selector-Based Component Consumption:
import { useUIStore } from "@/store/uiStore";
export const Sidebar = () => {
// Only re-renders if 'sidebarOpen' changes
const isOpen = useUIStore((state) => state.sidebarOpen);
return <div className={`w-64 bg-card-bg ${isOpen ? "block" : "hidden"}`}>Sidebar</div>;
};7. TypeScript Conventions: Boundaries and Types
The Problem: Duplicate Interfaces & Type Sprawl
In large applications, developers often declare similar interfaces in multiple places:
interface User in features/auth, interface UserProfile in features/billing, etc. When fields change, typescript compilations break in non-obvious ways.
The Solution: Clean Separation and Bound Typing
- Feature Types: Types that belong to a single domain reside inside
features/some-feature/types/index.ts. - Shared Types: Shared, cross-cutting models (e.g., Paginated responses) live in
src/types/index.ts. - Bound Request/Response Types: Place request payloads and API responses directly in the endpoint service files.
API Boundary Typing Example:
// src/features/project-board/api/createProject.ts
import { apiClient } from "@/lib/api-client";
import { Project } from "../types";
// Typed inputs
export interface CreateProjectPayload {
name: string;
description?: string;
deadline: string;
}
// Request bound directly to typed payload and return model
export const createProject = async (payload: CreateProjectPayload): Promise<Project> => {
const response = await apiClient.post<Project>("/projects", payload);
return response.data;
};8. Code Splitting Strategy: Keeping Bundles Slim
The Problem: Massive Initial JS Bundles
If your React build produces a single index.js larger than 500kb, users will experience blank loading states, especially on mobile connections.
The Solution: Route-Level Lazy Loading as a Standard
Always load primary pages asynchronously. Only load heavy features when the user navigates to their specific path.
Example: Routing Setup with React.lazy
// src/routes/AppRoutes.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { LoadingSpinner } from "@/components/LoadingSpinner";
// Lazy-loaded pages
const DashboardPage = lazy(() => import("@/pages/Dashboard"));
const SettingsPage = lazy(() => import("@/pages/Settings"));
const BillingPage = lazy(() => import("@/pages/Billing"));
export const AppRoutes = () => {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/billing" element={<BillingPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
};9. Error Handling Patterns: Defensive Architecture
The Problem: White Screen of Death
A single runtime error in a small widget should not crash the entire application layout.
The Solution: Nested Error Boundaries
Place Error Boundaries at critical levels of your application tree:
- Global Boundary: Catches catastrophic startup failures (wraps root).
- Layout/Route Boundary: Handles page-specific failures (wraps page content).
- Widget Boundary: Isolates non-essential features (e.g., a dashboard chart widget).
Example: Widget-Level Error Boundary Wrap
// src/components/ErrorBoundary/WidgetErrorBoundary.tsx
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div className="p-4 rounded-xl border border-rose-500/20 bg-rose-500/10 text-rose-500 text-xs">
<p className="font-bold">Failed to load panel</p>
<pre className="mt-1 text-[10px] opacity-80">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-3 py-1 bg-rose-500 text-white rounded-md font-semibold hover:bg-rose-600 transition-colors"
>
Retry
</button>
</div>
);
}
export const WidgetErrorBoundary = ({ children }: { children: React.ReactNode }) => {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(err) => {
// Send to monitoring service (Sentry, LogRocket, etc.)
console.error("Widget failure logged:", err);
}}
>
{children}
</ErrorBoundary>
);
};10. Testing Strategy: High Value over High Coverage
The Problem: Fragile Implementation Tests
Chasing 100% test coverage by testing implementation details (e.g., "does this component have state variable X") leads to fragile tests that break every time you refactor, without actually proving the application works.
The Solution: Pragmatic Testing Segregation
- Unit Tests: Run on pure helper functions (formatters, data math) and custom hooks.
- Integration Tests: Test user journeys inside feature modules using React Testing Library and Mock Service Worker (MSW) to mock network responses.
- E2E Tests (Playwright/Cypress): Reserved only for critical user paths (auth flow, checkout).
Example: Mock Service Worker (MSW) Integration Test
// src/features/project-board/components/Board.test.tsx
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { Board } from "./Board";
// Mock API server
const server = setupServer(
http.get("*/projects", () => {
return HttpResponse.json([
{ id: "1", name: "Mock Project Alpha", description: "First mock board item" }
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("loads and renders projects on the board", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<Board />
</QueryClientProvider>
);
// Check initial loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for MSW mock response to populate list
const projectTitle = await screen.findByText("Mock Project Alpha");
expect(projectTitle).toBeInTheDocument();
});11. Performance Patterns: Memoization Discipline
The Problem: Blindly Memoizing Everything
Wrapping every component in React.memo and every function in useCallback adds performance overhead. Comparing dependency arrays in JavaScript is not free.
The Solution: Strategic Memoization
Only use useMemo and useCallback in two scenarios:
- Expensive Computations: Heavy calculations (e.g., sorting 10,000 items, parsing large charts).
- Reference Equality: When passing objects, arrays, or functions as dependencies to custom hooks or child components that are optimized with
React.memo.
Example: Correct use of useMemo and useCallback
import React, { useMemo, useCallback } from "react";
export const SearchList = ({ items, onItemSelect }: { items: string[]; onItemSelect: (item: string) => void }) => {
// Scenario 1: Preventing expensive calculation from running on every minor render
const filteredList = useMemo(() => {
return items.filter(item => item.toLowerCase().includes("filter-match"));
}, [items]);
// Scenario 2: Preserving reference equality to prevent child from re-rendering
const handleSelect = useCallback((name: string) => {
onItemSelect(name);
}, [onItemSelect]);
return (
<div>
{filteredList.map(item => (
<ListItem key={item} name={item} onClick={handleSelect} />
))}
</div>
);
};
// Optimized child component
const ListItem = React.memo(({ name, onClick }: { name: string; onClick: (name: string) => void }) => {
return <div onClick={() => onClick(name)}>{name}</div>;
});
ListItem.displayName = "ListItem";12. Authentication & Protected Routes
The Problem: Flashing Content and Expired Sessions
Handling tokens and checking session validity incorrectly causes pages to briefly flash authenticated content before redirecting, or log users out abruptly.
The Solution: Route Guards with Session Context
Use a React context provider combined with router guards. Use Axios interceptors to transparently handle token refreshes in the background.
Example: Protected Router Guard (src/routes/ProtectedGuard.tsx)
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "@/features/auth/hooks/useAuth";
import { LoadingSpinner } from "@/components/LoadingSpinner";
export const ProtectedGuard = () => {
const { isAuthenticated, isSessionLoading } = useAuth();
const location = useLocation();
if (isSessionLoading) {
return <LoadingSpinner />;
}
// Redirect to login but save the attempted destination url
if (!isAuthenticated) {
return <Navigate to={`/login?auth=true&next=${encodeURIComponent(location.pathname)}`} replace />;
}
// Render child routes
return <Outlet />;
};13. Environment Config and Feature Flags
The Problem: Run-time Config Sprawl
Accessing process.env.NEXT_PUBLIC_API_URL or import.meta.env.VITE_API_URL directly inside component logic makes environments hard to manage and audit.
The Solution: Strictly-Typed App Config Wrapper
Read all environment variables at startup, validate their existence, and export a clean config module.
Step 1: Config Wrapper (src/core/config.ts)
const getEnvVar = (key: string): string => {
const value = import.meta.env[key];
if (value === undefined) {
throw new Error(`Critical Config Error: Environment variable ${key} is missing.`);
}
return value;
};
export const config = {
apiUrl: getEnvVar("VITE_API_URL"),
environment: getEnvVar("VITE_APP_ENV") as "development" | "staging" | "production",
analyticsId: import.meta.env.VITE_ANALYTICS_ID || "",
} as const;Step 2: Feature Flag Guard Component (src/components/FeatureFlag/FeatureFlag.tsx)
import React, { ReactNode } from "react";
import { useAuth } from "@/features/auth/hooks/useAuth";
interface FeatureFlagProps {
flag: string;
fallback?: ReactNode;
children: ReactNode;
}
export const FeatureFlag = ({ flag, fallback = null, children }: FeatureFlagProps) => {
const { user } = useAuth();
// Real-world implementation checks user traits or configuration states
const enabledFlags = user?.features || [];
const isEnabled = enabledFlags.includes(flag);
return isEnabled ? <>{children}</> : <>{fallback}</>;
};14. Closing: Core Architectural Axioms
When architecting massive frontend platforms, let these two core guidelines guide your decisions:
- Consistency over Cleverness: It is far better to have a slightly repetitive, standard folder structure that everyone understands than a clever, automated layout that only its creator knows how to modify.
- Optimize for the Onboard: Every structural decision you make should answer one question: "Will a new engineer joining the team next week be able to find where a bug is and build a new feature without asking for help?"
