Architecture
11 min read- React Architecture Best Practices (Interview-Ready)
- Table of Contents
- Component-Based Architecture
- Component Composition Rules
- Project & Folder Structure
- Feature-Based Structure (Recommended for Scale)
- Separation by Role/Type (Simpler, for Smaller Apps)
- State Management Strategy
- Data & API Layer Separation
- Layer Architecture
- Modular & Scalable Patterns
- Container / Presentational Pattern
- Atomic Design
- Code Splitting & Lazy Loading
- Performance Architecture
- Tooling & Code Quality
- Server-Side Rendering & Frameworks
React Architecture Best Practices (Interview-Ready)
Scalable React apps require intentional decisions about component design, folder structure, state management, data-fetching boundaries, and modular patterns. This guide covers the architectural principles that distinguish production-grade codebases from throwaway prototypes.
Table of Contents
- Component-Based Architecture
- Project & Folder Structure
- State Management Strategy
- Data & API Layer Separation
- Modular & Scalable Patterns
- Performance Architecture
- Tooling & Code Quality
Component-Based Architecture
React's core strength is its component model — build UI by composing small, reusable pieces. Each component should have a single responsibility: it either handles rendering or logic, not both.
Key principles:
- Prefer functional components with Hooks over class components.
- Keep components small: if a component exceeds ~200 lines, split it.
- Co-locate tests, styles, and types with the component for easier maintenance.
A: A well-designed component has a single responsibility, a clear props API, minimal internal state, and is testable in isolation. It handles either presentation or logic — not both.
A: Functional components are simpler, more composable, and avoid the complexity of this binding and lifecycle method timing. Hooks enable reusing stateful logic across components without inheritance or HOCs. Class components also don't work with React Server Components.
Component Composition Rules
- Lift state up only as far as necessary — the nearest common ancestor.
- Compose via children instead of nesting component definitions inside other components (this causes remounting on every render).
- Extract custom hooks when logic is reused across two or more components.
- Use
React.memoonly after profiling confirms unnecessary re-renders — premature memoization adds complexity for no gain.
✅ Good example: Composition via children
function Layout({ children }) {
return (
<div className="layout">
<Header />
<main>{children}</main>
<Footer />
</div>
);
}
function DashboardPage() {
return (
<Layout>
<DashboardContent />
</Layout>
);
}
❌ Bad example: Defining components inside other components
function DashboardPage() {
// This component is re-created every render — causes remounting
function DashboardContent() {
return <div>Dashboard</div>;
}
return (
<Layout>
<DashboardContent />
</Layout>
);
}
A: Because React creates a new component reference on every render, which causes the inner component to unmount and remount — destroying its state, losing focus, and hurting performance. Always define components at the module level.
Project & Folder Structure
Organizing source code purposefully makes large apps easier to develop and maintain. Two common approaches exist, and most mature codebases use a hybrid.
Feature-Based Structure (Recommended for Scale)
Group all files related to a feature together: components, hooks, services, types, tests.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── LoginForm.test.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ ├── types.ts
│ │ └── index.ts # public API barrel
│ ├── dashboard/
│ └── users/
├── components/ # shared/generic UI components
│ ├── Button/
│ ├── Modal/
│ └── DataTable/
├── hooks/ # shared hooks
├── api/ # shared API client/config
├── utils/ # pure utility functions
├── styles/ # global styles/themes
├── store/ # global state (if any)
└── App.tsx
Separation by Role/Type (Simpler, for Smaller Apps)
src/
├── components/
├── pages/
├── services/
├── hooks/
├── store/
├── utils/
└── styles/
A: Use feature-based for medium-to-large apps (5+ features, multiple developers). It keeps related code together, making it easier to navigate and modify a single feature without touching unrelated files. Type-based is acceptable for small apps but becomes unwieldy as the codebase grows because a single feature's code is scattered across many top-level folders.
A: A barrel file (index.ts) re-exports the public API of a module or feature. It controls what other parts of the app can import, creating a clean boundary. Use them at feature boundaries (e.g., features/auth/index.ts) to hide internal implementation details. Avoid deep barrel chains that hurt tree-shaking and create circular dependencies.
State Management Strategy
React doesn't mandate a state solution, but choosing the right one for each type of state matters:
| State Type | Where It Lives | Tool |
|---|---|---|
| Local UI state | Component | useState, useReducer |
| Shared UI state | Nearest common ancestor or Context | useContext, Zustand, Jotai |
| Server/cache state | Data-fetching layer | TanStack Query, SWR, RTK Query |
| URL state | Browser URL | React Router useSearchParams |
| Form state | Form library | React Hook Form, Formik |
Key rule: Keep state as close to where it's needed as possible. Don't hoist state to a global store when it's only used by two sibling components.
A: Server state is data owned by the server (API responses, database records) — it's asynchronous, can become stale, and has a source of truth elsewhere. Client state is data owned by the frontend (UI toggles, form inputs, selected tabs). The distinction matters because server state needs caching, background refetching, and invalidation — concerns that Redux or useState don't handle well. Libraries like TanStack Query are purpose-built for server state.
A: Use Context for low-frequency updates shared across many components (theme, auth, locale). Use a dedicated library (Zustand, Redux Toolkit) when you have frequent updates to shared state, need middleware (logging, persistence), or need to select slices of state without re-rendering the entire tree. Context causes all consumers to re-render when the value changes, which is problematic for frequently-changing state.
Data & API Layer Separation
Separating UI from business logic and data fetching improves flexibility and testability.
Layer Architecture
┌─────────────────────────┐
│ UI Components │ ← Renders props, handles user events
├─────────────────────────┤
│ Custom Hooks │ ← Orchestrates logic, calls API layer
├─────────────────────────┤
│ API / Service Layer │ ← HTTP calls, request/response transforms
├─────────────────────────┤
│ API Client (Axios/ │ ← Base config, interceptors, auth headers
│ fetch wrapper) │
└─────────────────────────┘
✅ Good example: Separated layers
// api/userApi.ts — pure data fetching
export async function fetchUsers(): Promise<User[]> {
const response = await apiClient.get('/users');
return response.data;
}
// hooks/useUsers.ts — data orchestration
export function useUsers() {
return useQuery({ queryKey: ['users'], queryFn: fetchUsers });
}
// components/UserList.tsx — pure rendering
export function UserList() {
const { data: users, isLoading, error } = useUsers();
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <ul>{users.map(u => <UserCard key={u.id} user={u} />)}</ul>;
}
❌ Bad example: Everything in one component
export function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => { setUsers(data); setLoading(false); })
.catch(err => console.error(err));
}, []);
if (loading) return <div>Loading...</div>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
A: Separation enables independent testing (mock the API layer without rendering components), reuse (multiple components can call the same API function), and flexibility (swap HTTP clients or add caching without touching UI code). It also keeps components focused on rendering, making them easier to reason about.
Modular & Scalable Patterns
As the app grows, adopt higher-level architectural patterns:
Container / Presentational Pattern
Separate data-handling components ("containers" or "smart" components) from pure UI components ("presentational" or "dumb" components).
// Presentational — receives data via props, no side effects
function UserCard({ name, email, avatar }: UserCardProps) {
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
</div>
);
}
// Container — fetches data, passes to presentational
function UserCardContainer({ userId }: { userId: string }) {
const { data: user } = useUser(userId);
if (!user) return null;
return <UserCard name={user.name} email={user.email} avatar={user.avatar} />;
}
With Hooks, the "container" is often just a custom hook rather than a wrapper component.
Atomic Design
Structure UI into hierarchical layers for reusable component libraries:
- Atoms: Smallest units — Button, Input, Label, Icon
- Molecules: Groups of atoms — SearchBar (Input + Button), FormField (Label + Input + Error)
- Organisms: Complex sections — NavigationBar, UserProfile, DataTable
- Templates: Page layouts with placeholder slots
- Pages: Templates filled with real data
A: It separates data-fetching logic from rendering. Containers manage state and side effects; presentational components are pure functions of their props. With Hooks, the "container" is often replaced by a custom hook, but the underlying principle — separating concerns — remains essential. The pattern is still relevant; only the implementation mechanism has changed.
A: Atomic Design organizes components into five levels: Atoms, Molecules, Organisms, Templates, and Pages. It's useful for design systems and component libraries where you need consistent, composable UI primitives. For feature-driven apps, a feature-based structure is usually more practical — you can apply Atomic principles within each feature's components/ folder.
Code Splitting & Lazy Loading
Split your bundle by route to avoid loading the entire app upfront:
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./features/dashboard/DashboardPage'));
const Settings = lazy(() => import('./features/settings/SettingsPage'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
A: Code splitting with React.lazy and dynamic import() breaks the app into smaller bundles loaded on demand. This reduces initial load time because users only download code for the route they visit. Combined with Suspense, it provides a declarative loading experience. Split at route boundaries first; split heavy feature modules only if needed after measuring bundle size.
Performance Architecture
Performance should be built into the architecture, not bolted on later:
- Memoize expensive computations with
useMemo— but only after profiling confirms the cost. - Stabilize callback references with
useCallbackwhen passing handlers to memoized children. - Virtualize long lists with
react-windowor@tanstack/react-virtualinstead of rendering thousands of DOM nodes. - Debounce user input for search and filter operations that trigger API calls.
- Use
React.memoon pure presentational components that receive stable primitive props and re-render too often.
useMemo and useCallback?A: Use useMemo when a computation is expensive AND runs on every render with the same inputs. Use useCallback when you pass a callback to a memoized child component and want to prevent unnecessary re-renders. Don't use them by default — they add memory overhead and complexity. Profile first, then optimize.
Tooling & Code Quality
Good architecture includes enforcing standards at the tooling level:
- TypeScript: Provides type safety, better IDE support, and self-documenting interfaces. Define prop types explicitly instead of relying on inference for public component APIs.
- ESLint + Prettier: Enforce consistent formatting and catch common mistakes. Use
eslint-plugin-react-hooksfor rules-of-hooks enforcement. - Custom Hooks for Reuse: Encapsulate any reusable stateful logic in custom hooks. Name them
useXxxso the linter can enforce hook rules. - Strict Mode: Wrap the app in
<React.StrictMode>during development to surface impure renders and missing cleanup.
A: TypeScript catches type errors at compile time, provides autocompletion and refactoring support, and serves as living documentation for component props and API contracts. It's especially valuable in large codebases with multiple developers where props interfaces change frequently. The investment pays off in fewer runtime bugs and more confident refactoring.
A: <React.StrictMode> intentionally double-invokes certain lifecycle functions (renders, effects) during development to help you find impure renders and side effects that don't clean up properly. It also warns about deprecated APIs. It has no effect in production builds. Wrapping your app in Strict Mode catches subtle bugs early — particularly around effects that forget cleanup.
Server-Side Rendering & Frameworks
For apps that need SEO, fast initial load, or server-rendered HTML, consider a React framework:
- Next.js: Full-featured framework with SSR, SSG, ISR, API routes, and App Router (React Server Components).
- Remix: Focuses on web standards, progressive enhancement, and nested routing with data loading co-located per route.
- Vite + React: Client-only SPA setup with fast dev server and optimized builds. Best for internal tools and dashboards that don't need SEO.
A: Use Next.js when you need server-side rendering (SEO, social sharing, fast first paint), static site generation, or API routes in a single project. For internal tools, dashboards, or apps behind authentication where SEO doesn't matter, a Vite-based SPA is simpler and avoids the complexity of server/client boundaries. Next.js adds significant architectural constraints (file-based routing, server/client component split) that are only worth adopting if you need the capabilities.