Building Scalable React Applications: Best Practices and Architecture Patterns
A practical guide to React app architecture: feature-based structure, enforced module boundaries, state management decisions, and incremental refactoring.
- React
- Architecture
- Performance
- Best Practices
- State Management
- TypeScript
React application architecture is the set of decisions that determine whether your codebase stays workable at 200 components or becomes something the team dreads opening. The best practices that matter most are: organize by feature rather than by file type, enforce explicit boundaries between modules, match each kind of state to the tool designed for it, and keep your data layer separate from your UI.
None of that is controversial. The hard part is that React does not enforce any of it. React is a rendering library with opinions about components and nothing to say about folders, boundaries, or where business logic lives. That freedom is why React scales to almost any product, and also why so many React codebases become difficult to work in after eighteen months.
What follows is the architecture I use on production applications — SaaS dashboards, multi-tenant platforms, and data-heavy internal tools — along with the reasoning behind each decision and the failure mode it prevents.
Why React Applications Become Hard to Scale
Scaling problems rarely announce themselves. They accumulate through reasonable individual decisions:
- A component needs data, so it fetches directly in a
useEffect. - Two features need the same helper, so it moves to
utils/. - A prop needs to reach four levels down, so it goes into a context.
- A context now holds unrelated concerns, so every consumer re-renders.
- A file imports from six different features, so nothing can be changed safely.
Each step is defensible. The cumulative result is a codebase where the blast radius of any change is unknowable. The symptom developers report is "everything is coupled." The cause is almost always that no boundaries were ever declared, so none could be violated.
Good architecture is mostly the practice of declaring boundaries early, when declaring them is cheap.
Project Structure: Organize by Feature, Not by Type
The most common React structure groups files by what they are:
src/
├── components/ # 140 files
├── hooks/ # 40 files
├── utils/ # 30 files
├── services/ # 25 files
└── types/ # 20 files
This works until roughly 20 components. Past that it fails for a specific reason: nothing in the structure tells you what the application does, and no directory has a boundary. To change the checkout flow you open five directories and hope you found every file. To delete a feature you grep.
Feature-based structure groups files by what they are for:
src/
├── features/
│ ├── authentication/
│ │ ├── components/ # LoginForm, PasswordReset
│ │ ├── hooks/ # useAuth, useSession
│ │ ├── api/ # login, logout, refresh
│ │ ├── types/ # User, AuthState
│ │ └── index.ts # the public surface
│ ├── billing/
│ └── dashboard/
├── shared/
│ ├── ui/ # Button, Modal, Table — no business logic
│ ├── hooks/ # useDebounce, useMediaQuery
│ ├── lib/ # formatting, dates, validation
│ └── types/
└── app/ # routing, providers, layout, entry point
The benefits are concrete rather than aesthetic:
| Property | By type | By feature |
|---|---|---|
| Find everything for one feature | Search five directories | Open one directory |
| Delete a feature | Grep and pray | Delete the folder |
| Onboard a new developer | Read the whole tree | Read one folder |
| Parallel team work | Constant conflicts | Ownership is obvious |
| Enforce boundaries | Nothing to enforce | Lint rule on imports |
The Three-Directory Rule
Every file belongs in exactly one of three places, and the test is simple:
features/— used by one feature. Default here. Most code lives here.shared/— used by three or more features, with no business logic. Promote a file here only when the third consumer appears.app/— wiring: routes, providers, global layout.
The "three or more" threshold matters. Promoting on the second use produces a shared/ directory full of near-duplicates that each feature has to work around. Duplication is cheaper than a wrong abstraction, and the third use is where the real shape becomes visible.
Module Boundaries Are What Actually Prevent Coupling
Feature folders alone do nothing. A folder is a suggestion until an import rule makes it a boundary. This is the part most architecture guides omit, and it is the part that does the work.
Two rules hold the structure together:
Rule one: features expose a public surface through index.ts, and nothing else is importable.
// features/authentication/index.ts — the entire public API of this feature
export { LoginForm } from "./components/LoginForm";
export { useAuth } from "./hooks/useAuth";
export type { User, AuthState } from "./types";
// Everything else — internal components, API clients, helpers — stays private.
// ✅ allowed
import { useAuth } from "@/features/authentication";
// ❌ reaches past the boundary into private internals
import { parseToken } from "@/features/authentication/lib/parseToken";
Rule two: features do not import from each other.
If billing needs the current user, that dependency is real and it should be explicit. Either lift the shared concept into shared/, or have the app/ layer compose the two features together by passing data down. Cross-feature imports are how a feature-based structure quietly turns back into a ball of mud.
You can enforce both rules with ESLint rather than code review:
// eslint.config.mjs
{
rules: {
"no-restricted-imports": ["error", {
patterns: [
{
group: ["@/features/*/*"],
message: "Import from the feature's index.ts, not its internals.",
},
],
}],
},
}
A boundary a linter checks is an architecture. A boundary in a README is a preference.
State Management: Match the Tool to the Kind of State
Most state management pain comes from treating "state" as one category. It is at least five, and each has a correct home:
| Kind of state | Example | Where it belongs |
|---|---|---|
| Server state | Projects list, user profile | TanStack Query / SWR |
| URL state | Filters, pagination, tab | The URL itself |
| Form state | In-progress input | React Hook Form / local |
| Local UI state | Modal open, hover | useState in the component |
| Global client state | Theme, sidebar, feature flags | Zustand / Context |
Work down that list in order. The single most valuable rule is the first one: server data is not application state. It is a cache of something that lives on a server and can go stale.
Putting server data in Redux or Zustand means hand-writing loading flags, error flags, refetch logic, invalidation, and race-condition handling — a large amount of code that a query library already solved:
// features/projects/hooks/useProjects.ts
import { useQuery } from "@tanstack/react-query";
import { projectApi } from "../api/projectApi";
export function useProjects(workspaceId: string) {
return useQuery({
queryKey: ["projects", workspaceId],
queryFn: () => projectApi.list(workspaceId),
staleTime: 5 * 60 * 1000,
});
}
That hook gives you caching, deduplication, background refetching, and typed loading and error states. See TanStack Query: Modern Data Fetching in React for the caching and invalidation patterns in depth.
The second rule: put state in the URL whenever a user might reasonably reload, share, or bookmark the page. Filters, search terms, active tabs, and pagination all belong there. It costs nothing and removes a whole category of "why did my filters reset" bugs.
By the time you have applied both rules, genuinely global client state is usually small — theme, sidebar collapse, a few flags. That is a job for Zustand or a narrow context, not a full Redux setup.
Split Contexts by Update Frequency
When you do reach for context, the common mistake is one large context holding unrelated values. Every consumer re-renders when any value changes.
// ❌ theme change re-renders every component that only wanted `user`
const AppContext = createContext({ user, theme, notifications, sidebarOpen });
// ✅ separate contexts, separate update cadences
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const SidebarContext = createContext(sidebarOpen);
Split by how often the value changes, not by what feels topically related.
Keep the Data Layer Out of Your Components
Components that call fetch directly are hard to test, hard to reuse, and duplicate error handling everywhere. Put an explicit API layer between the network and the UI.
// features/projects/api/projectApi.ts
import { apiClient } from "@/shared/lib/apiClient";
import type { Project } from "../types";
export const projectApi = {
list: (workspaceId: string) =>
apiClient.get<Project[]>(`/workspaces/${workspaceId}/projects`),
create: (workspaceId: string, input: CreateProjectInput) =>
apiClient.post<Project>(`/workspaces/${workspaceId}/projects`, input),
};
This gives you three layers with clear responsibilities:
- API layer — talks to the network, returns typed data.
- Hook layer — caching, invalidation, derived state.
- Component layer — rendering and user interaction only.
Each layer is independently testable, and swapping REST for GraphQL becomes one file rather than forty. The same boundary applies with Server Components — see React 19 Server Actions: The Complete Guide for the server-side equivalent.
Validate at the Boundary
Your TypeScript types describe what you expect from the API. They are erased at runtime and guarantee nothing. Validate where data enters the application:
import { z } from "zod";
const projectSchema = z.object({
id: z.string(),
title: z.string(),
createdAt: z.string().datetime(),
});
export const projectApi = {
list: async (workspaceId: string) => {
const raw = await apiClient.get(`/workspaces/${workspaceId}/projects`);
return z.array(projectSchema).parse(raw);
},
};
A malformed API response now fails at the boundary with a clear message, instead of surfacing as undefined is not an object three components deep.
Component Patterns That Survive Growth
Compound Components
When a component has several coordinated parts, a compound API keeps it flexible without a prop explosion:
export const Modal = ({ children, isOpen, onClose }: ModalProps) => (
<ModalContext.Provider value={{ isOpen, onClose }}>
{children}
</ModalContext.Provider>
);
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
<Modal isOpen={isOpen} onClose={handleClose}>
<Modal.Header><h2>Confirm action</h2></Modal.Header>
<Modal.Body><p>This cannot be undone.</p></Modal.Body>
<Modal.Footer>
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</Modal.Footer>
</Modal>
The alternative — showHeader, headerTitle, footerButtons, hideCancel — grows a new prop for every variation until nobody can safely change the component.
Custom Hooks for Logic, Not for Tidiness
Extract a hook when logic is genuinely reused or genuinely complex. Extracting every three-line useState into its own file adds indirection without reducing complexity.
export function useLocalStorage<T>(key: string, initialValue: T) {
const [stored, setStored] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStored((prev) => {
const next = value instanceof Function ? value(prev) : value;
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch (error) {
console.error("Failed to persist to localStorage:", error);
}
return next;
});
},
[key]
);
return [stored, setValue] as const;
}
Note the functional update inside setStored — it keeps the callback stable across renders, which matters when it is passed to memoized children.
Performance: Structure First, Memoization Second
Most React performance problems are architectural. A component re-rendering too often is usually consuming state it does not need, or sitting below a context that changes constantly. Fix the structure before reaching for useMemo.
The reliable structural wins, in order:
- Route-based code splitting — nobody should download the admin panel to view a dashboard.
- Move state down — if only one subtree needs it, it does not belong at the top.
- Lift content up — pass expensive children as
props.childrenso they do not re-render with the parent. - Split contexts — as described above.
- Then memoize what profiling shows is actually hot.
const Dashboard = lazy(() => import("@/features/dashboard"));
const Billing = lazy(() => import("@/features/billing"));
export const AppRouter = () => (
<Suspense fallback={<RouteSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/billing" element={<Billing />} />
</Routes>
</Suspense>
);
Feature-based structure makes this nearly free — each feature folder is already a natural code-splitting boundary.
One update worth planning for: the React Compiler now handles most memoization automatically, which removes a large share of manual useMemo and useCallback from new code. It does not fix architectural re-render problems, so the ordering above still holds. I covered what changes in practice in React Compiler Adoption in 2026.
Error Handling and Loading States by Design
Error boundaries belong at several levels, not just around the whole app. One top-level boundary turns any failure into a blank page.
Place them at three levels: the app root as a last resort, each route so a broken page does not take down navigation, and around independent widgets so one failing chart does not blank the dashboard.
interface State { hasError: boolean; error?: Error }
export class ErrorBoundary extends Component<PropsWithChildren<{
fallback: (error: Error, reset: () => void) => ReactNode;
}>, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
reportError(error, { componentStack: info.componentStack });
}
reset = () => this.setState({ hasError: false, error: undefined });
render() {
if (this.state.hasError && this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}
Passing fallback as a function lets each boundary render an appropriate recovery path and offer a retry, rather than telling every user to refresh the page.
Every async view has four states — loading, empty, error, and success. Designing all four up front is the difference between an app that feels finished and one that flashes broken layouts.
Testing That Matches the Architecture
The layers give you a natural testing strategy:
| Layer | What to test | Tool |
|---|---|---|
shared/lib | Pure functions, edge cases | Vitest / Jest |
| API layer | Request shape, response parsing | Vitest + MSW |
| Hooks | Caching, state transitions | Testing Library |
| Components | Behaviour users can observe | Testing Library |
| Critical flows | Signup, checkout, core journey | Playwright |
Test the public surface of each feature — the things exported from index.ts — rather than its internals. Tests coupled to internal structure break on every refactor and gradually train the team to distrust the suite. More on this in React Testing Strategies.
Refactoring an Application That Already Outgrew Its Structure
Most teams asking about React architecture are not starting fresh. A full rewrite is almost never the right call — it freezes feature work for months and usually reproduces the original problems in new locations.
Do it incrementally instead:
- Add the lint rule first, scoped to new code. Stop the bleeding before cleaning up.
- Create
features/alongside the existing structure. Both can coexist. - Move one feature — the one you are already changing this sprint. Real work funds the migration.
- Give it an
index.tsand fix the imports it exposes. The compiler lists your work. - Extract shared pieces only when a third consumer appears.
- Repeat, one feature per sprint.
After a few cycles the remaining unmigrated code is the code nobody touches — which is exactly the code least worth migrating. Teams that ship this way get most of the benefit in a quarter without pausing the roadmap.
If the application has genuinely outgrown a single codebase — several teams deploying independently — that is the point to evaluate micro-frontends, not before. Micro-frontends solve an organizational problem and add real operational cost; reaching for them to fix a structure problem trades one difficulty for a harder one.
Anti-Patterns Worth Naming
| Anti-pattern | Why it hurts | Do instead |
|---|---|---|
utils/index.ts with 60 exports | Becomes an import magnet coupling everything | Group by purpose in shared/lib |
| Server data in Redux | Reimplements caching by hand | Query library |
| One giant app context | Every change re-renders everything | Split by update frequency |
| Barrel-exporting entire features | Defeats tree shaking, creates cycles | Export only the public surface |
useEffect for derived values | Extra render, easy to desync | Compute during render |
| Prop drilling five levels | Every layer knows too much | Composition or context |
Premature shared/ promotion | Wrong abstraction, worse than duplication | Wait for the third use |
Key Takeaways
- Organize by feature, not by file type. Structure should describe what the product does.
- Boundaries need enforcement. A lint rule on imports is what turns folders into architecture.
- Server state is not application state. Give it to a query library and delete the boilerplate you wrote by hand.
- Put state in the URL whenever a user might reload or share the page.
- Separate the data layer into api → hook → component, and validate at the boundary.
- Fix structure before memoizing. The React Compiler handles the mechanical part; it cannot fix a component reading state it does not need.
- Migrate incrementally, one feature per sprint, funded by work you were doing anyway.
Scalability is not about handling more users. It is about keeping the cost of the next change roughly constant as the codebase grows. Every pattern here exists to make one thing true: when a requirement changes, you should be able to predict which files you need to open.
Related Reading
- TanStack Query: Modern Data Fetching in React
- React Compiler Adoption in 2026
- React Testing Strategies
- Micro-Frontends: Scaling Large React Applications in 2026
- TypeScript 5.x: Type System Evolution for React
Architecture work is easiest to get right early and expensive to retrofit. If you are scaling a React codebase and the structure is starting to fight you, this is exactly what I do as React and Next.js web application development — tell me what you are building.