Skip to content
Sameer Sabir
ArchitectureFeatured

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.

Sameer SabirUpdated 14 min read
  • 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:

PropertyBy typeBy feature
Find everything for one featureSearch five directoriesOpen one directory
Delete a featureGrep and prayDelete the folder
Onboard a new developerRead the whole treeRead one folder
Parallel team workConstant conflictsOwnership is obvious
Enforce boundariesNothing to enforceLint rule on imports

The Three-Directory Rule

Every file belongs in exactly one of three places, and the test is simple:

  1. features/ — used by one feature. Default here. Most code lives here.
  2. shared/ — used by three or more features, with no business logic. Promote a file here only when the third consumer appears.
  3. 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 stateExampleWhere it belongs
Server stateProjects list, user profileTanStack Query / SWR
URL stateFilters, pagination, tabThe URL itself
Form stateIn-progress inputReact Hook Form / local
Local UI stateModal open, hoveruseState in the component
Global client stateTheme, sidebar, feature flagsZustand / 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:

  1. API layer — talks to the network, returns typed data.
  2. Hook layer — caching, invalidation, derived state.
  3. 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:

  1. Route-based code splitting — nobody should download the admin panel to view a dashboard.
  2. Move state down — if only one subtree needs it, it does not belong at the top.
  3. Lift content up — pass expensive children as props.children so they do not re-render with the parent.
  4. Split contexts — as described above.
  5. 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:

LayerWhat to testTool
shared/libPure functions, edge casesVitest / Jest
API layerRequest shape, response parsingVitest + MSW
HooksCaching, state transitionsTesting Library
ComponentsBehaviour users can observeTesting Library
Critical flowsSignup, checkout, core journeyPlaywright

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:

  1. Add the lint rule first, scoped to new code. Stop the bleeding before cleaning up.
  2. Create features/ alongside the existing structure. Both can coexist.
  3. Move one feature — the one you are already changing this sprint. Real work funds the migration.
  4. Give it an index.ts and fix the imports it exposes. The compiler lists your work.
  5. Extract shared pieces only when a third consumer appears.
  6. 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-patternWhy it hurtsDo instead
utils/index.ts with 60 exportsBecomes an import magnet coupling everythingGroup by purpose in shared/lib
Server data in ReduxReimplements caching by handQuery library
One giant app contextEvery change re-renders everythingSplit by update frequency
Barrel-exporting entire featuresDefeats tree shaking, creates cyclesExport only the public surface
useEffect for derived valuesExtra render, easy to desyncCompute during render
Prop drilling five levelsEvery layer knows too muchComposition or context
Premature shared/ promotionWrong abstraction, worse than duplicationWait 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.

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.

  1. Micro-Frontends: Scaling Large React Applications in 2026Architecture · 11 min
  2. Zero-JavaScript Architecture: When and How to Use It Effectively in 2026Architecture · 11 min
  3. Streaming in Serverless: How Edge Computing is Reshaping React ApplicationsArchitecture · 10 min

Next step

Have a project that needs this kind of work?

I build React and Next.js products end to end, including the performance and AI work these posts cover. Tell me where yours is stuck.