Difficulty: Advanced
How would you architect a large-scale React application? What are the key decisions?
System design questions for React are where interviews shift from 'do you know the API' to 'can you build something real.' They test your ability to make architectural decisions, understand tradeoffs, and think about scalability. Let me walk you through how to think about architecting a large-scale React application, because this isn't something you can just memorize -- you need to understand the reasoning behind each decision.
Let's start with project structure. There are two main approaches: layer-based and feature-based. Layer-based means you have top-level folders for components/, hooks/, services/, utils/, types/. This works fine for small apps, but it falls apart as the app grows. Your components/ folder ends up with 200 files, and you can't tell which ones are related to which feature. Deleting a feature means hunting through every folder.
Feature-based structure groups code by domain. You have features/auth/, features/products/, features/orders/. Each feature folder contains its own components, hooks, API calls, types, and state management. The benefit is huge: each feature is self-contained. You can understand the auth feature by looking at one folder. You can delete the orders feature by removing one folder. New developers can work on one feature without understanding the entire codebase.
The key rule for feature-based structure is import boundaries. Features should not import from each other directly. features/auth/ should not import from features/products/. If two features need to share something, it goes in shared/ or lib/. You can enforce this with ESLint rules (eslint-plugin-import). This prevents the spaghetti dependencies that make large codebases unmaintainable.
Pages (route components) should be thin. They import and compose feature components, set up layouts, and handle route parameters. They should not contain business logic. Think of pages as the glue between features and the router.
Next, state management. This is where a lot of developers make poor choices, usually by over-centralizing. The right approach is to use different tools for different types of state:
Local component state (useState): for UI state that only one component needs. This should be your default.
Server state (React Query or SWR): for data fetched from APIs. This is the most important category to get right. Server state has unique requirements -- caching, background refetching, cache invalidation, optimistic updates, pagination, infinite scroll. React Query handles all of these out of the box. Trying to build this yourself with useEffect + useState is a common mistake that leads to stale data bugs, race conditions, and missing loading/error states.
Client state (Zustand or Redux): for truly global client-side state that multiple unrelated components need. Authentication state, theme preferences, feature flags. Zustand is simpler and has less boilerplate; Redux has better devtools and middleware support for complex state logic. For most apps in 2024+, Zustand is the right choice.
URL state (useSearchParams, route params): for state that should be shareable and bookmarkable. Filters, sort order, pagination, selected tabs. This is criminally underused.
The data layer architecture should follow a clean separation. Raw API functions (pure fetch/axios calls) live in an api/ folder. Custom hooks wrap these in React Query's useQuery and useMutation. Components call the hooks and render the data. This gives you clear layers: transport (api) -> caching/state (hooks) -> presentation (components). Each layer is independently testable.
For the component architecture, follow a principle of composition over configuration. Instead of a MegaComponent with 30 props that handles every variation, compose smaller, focused components. A DataTable is composed of TableHeader, TableBody, TableRow, TableCell, Pagination, and SortIndicator. Each is simple, testable, and reusable independently.
Performance strategy should be baked into the architecture, not bolted on later. Route-based code splitting from day one -- every page should be a lazy-loaded chunk. Virtualization for any list that might exceed 100 items. Image optimization (next/image in Next.js, or a CDN with transformation). These aren't premature optimizations; they're architectural decisions that are expensive to retrofit.
Error handling needs a layered approach too. Error boundaries per feature section prevent one crashed widget from taking down the entire page. API error handling in your React Query configuration provides consistent retry logic, error transformation, and global error notifications. Form validation errors handled by React Hook Form + Zod at the component level.
Styling choice matters for team scalability. Tailwind CSS has become the dominant choice because it eliminates naming debates, keeps styles colocated with components, and produces small CSS bundles through purging. CSS Modules are a solid alternative if the team prefers writing traditional CSS. CSS-in-JS (styled-components, Emotion) is losing popularity due to runtime performance costs and SSR complexity.
Testing strategy follows the testing trophy, not the testing pyramid. The bulk of your tests should be integration tests using React Testing Library -- render a component, simulate user interactions, assert on visible outcomes. Unit tests for pure utility functions and complex business logic. A small number of end-to-end tests with Playwright or Cypress for critical user journeys (login, checkout, signup). Snapshot tests are generally discouraged -- they have high maintenance cost and low bug-catching value.
Finally, a word about monorepos and micro-frontends. For teams over 10-15 developers, a monorepo with tools like Turborepo or Nx lets multiple teams work in the same repository with shared configurations and dependencies. Micro-frontends (Module Federation, Single-SPA) let teams deploy independently, but add significant complexity and should only be used when organizational independence is more important than technical simplicity.
When answering system design questions in interviews, always explain the tradeoffs. There's no universally correct architecture -- there are choices that make sense given specific constraints (team size, timeline, user requirements, scale). Showing that you understand why you'd choose one approach over another is far more impressive than just listing technologies.
// Scalable folder structure
src/
features/ # Feature modules (domain-driven)
auth/
components/ # Auth UI components
hooks/ # useAuth, usePermissions
store/ # Auth state (Zustand slice)
api/ # Auth API calls
types.ts
products/
components/
hooks/
store/
api/
orders/
shared/ # Truly reusable across features
components/ # Button, Modal, Table
hooks/ # useDebounce, useLocalStorage
utils/
types/
pages/ # Route components (thin - import features)
lib/
api-client.ts # Axios instance
query-client.ts # React Query config
App.tsx
// Key principles:
// 1. Features are self-contained (can be deleted without breaking others)
// 2. Shared code is truly shared (used by 2+ features)
// 3. Pages are thin - they compose feature components
// 4. Collocate tests with components (__tests__/ or *.test.tsx)
// Import boundaries (enforce with eslint-plugin-import)
// features/auth can NOT import from features/products
// features/* can import from shared/*
// pages/* can import from features/* and shared/*
Feature-based structure scales better than layer-based (components/, services/, hooks/). Each feature owns its components, hooks, and state.
// React Query as the data layer
// 1. API layer (pure fetch functions)
const api = {
getProducts: (filters) =>
axios.get('/products', { params: filters }).then(r => r.data),
createProduct: (data) =>
axios.post('/products', data).then(r => r.data),
};
// 2. Custom hooks (query wrappers)
function useProducts(filters) {
return useQuery({
queryKey: ['products', filters],
queryFn: () => api.getProducts(filters),
staleTime: 5 * 60 * 1000, // 5 min
});
}
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
// 3. Components (thin - just UI + hook calls)
function ProductList({ filters }) {
const { data, isLoading, error } = useProducts(filters);
const { mutate: createProduct } = useCreateProduct();
if (isLoading) return <Skeleton />;
if (error) return <ErrorState error={error} />;
return <ul>{data.map(p => <ProductCard key={p.id} product={p} />)}</ul>;
}
Layered data architecture: API functions → custom hooks → thin components. React Query handles caching, background refetch, loading/error states.
System Design, Architecture, Trade-offs, Scalability, Component Design