How Modern Frontend Architectures Handle Large Applications
Small React applications are simple. One App.tsx, a few components, a context or two. Large applications — 100+ components, multiple teams, feature flags, and A/B tests — require architectural decisions that small applications never encounter.
This guide covers the patterns that frontend teams at scale use to keep large codebases maintainable.
Problem
Large frontend applications develop these symptoms:
- Changing one component breaks something in a different feature
- Build times exceed 5 minutes
- New developers take weeks to become productive
- Feature flags create unmaintainable conditional logic
- Bundle size grows past 1 MB
Feature-Based Folder Structure
Organize by feature, not by type:
BAD (organized by type):
src/
components/
Button.tsx
UserProfile.tsx
InvoiceTable.tsx
ChatMessage.tsx
hooks/
useAuth.ts
useInvoices.ts
useChat.ts
services/
authService.ts
invoiceService.ts
chatService.ts
GOOD (organized by feature):
src/
features/
auth/
components/LoginForm.tsx
hooks/useAuth.ts
services/authService.ts
auth.types.ts
billing/
components/InvoiceTable.tsx
hooks/useInvoices.ts
services/invoiceService.ts
chat/
components/ChatMessage.tsx
hooks/useChat.ts
services/chatService.ts
shared/
components/Button.tsx
hooks/useDebounce.ts
Features can be developed, tested, and deployed independently. Cross-feature imports go through the shared/ directory.
State Management at Scale
Local State First
Most state does not need to be global:
// GOOD: Form state is local
function CreateProjectForm() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// Only this component needs this state
}
Server State with React Query
Data from APIs is server state, not client state:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function ProjectList() {
const { data, isLoading } = useQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
staleTime: 5 * 60 * 1000,
});
const queryClient = useQueryClient();
const createProject = useMutation({
mutationFn: createProjectAPI,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
}
React Query handles caching, background refetching, and cache invalidation. You do not need Redux for server data.
Global State for UI
Use lightweight stores like Zustand for genuinely global UI state:
import { create } from "zustand";
interface UIStore {
sidebarOpen: boolean;
theme: "light" | "dark";
toggleSidebar: () => void;
setTheme: (theme: "light" | "dark") => void;
}
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
theme: "dark",
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
Code Splitting
Split by route and by feature:
const Dashboard = lazy(() => import("./features/dashboard/Dashboard"));
const Billing = lazy(() => import("./features/billing/Billing"));
const Settings = lazy(() => import("./features/settings/Settings"));
For large features, split internally:
// Inside the billing feature
const InvoiceDetail = lazy(() => import("./components/InvoiceDetail"));
const PaymentHistory = lazy(() => import("./components/PaymentHistory"));
Design System
Shared components live in a design system, not scattered across features:
// shared/components/Button.tsx
interface ButtonProps {
variant: "primary" | "secondary" | "ghost";
size: "sm" | "md" | "lg";
loading?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
function Button({ variant, size, loading, children, onClick }: ButtonProps) {
return (
<button
className={cn(baseStyles, variantStyles[variant], sizeStyles[size])}
disabled={loading}
onClick={onClick}
>
{loading ? <Spinner size={size} /> : children}
</button>
);
}
Error Boundaries
Isolate feature failures:
function App() {
return (
<Layout>
<ErrorBoundary fallback={<DashboardError />}>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary fallback={<ChatError />}>
<Chat />
</ErrorBoundary>
</Layout>
);
}
A crash in Chat does not take down Dashboard.
Common Mistakes
Mistake 1: Putting everything in global state. If only one component uses a piece of state, it should be local. Global state creates coupling.
Mistake 2: No code splitting. A 2 MB bundle means every user downloads every feature on every page load. Split by route at minimum.
Mistake 3: Shared components without contracts. Design system components need typed props, documented variants, and visual tests. Without them, every team builds their own button.
Takeaways
Large frontend applications need feature-based folder structure, layered state management (local → server → global), aggressive code splitting, and a design system. These patterns prevent the complexity from growing with the codebase. The best architecture is the one your team can maintain — not the most sophisticated one.