DEV Community

Cover image for Scaling React: 5 Tough Lessons Learned from Real-World Projects
Gouranga Das Samrat
Gouranga Das Samrat

Posted on

Scaling React: 5 Tough Lessons Learned from Real-World Projects

When you’re just getting started with React, it’s tempting to focus on features and ship fast. But as your application scales — more users, more features, more developers — chaos grows faster than code unless you tame it with structure, clarity, and boundaries.

This blog dives deep into how to architect a React codebase for production, with lessons I have drawn from dozens of enterprise level projects I have worked on. Whether you’re a tech lead or an ambitious solo dev, this will help you build something that doesn’t collapse under its own weight.

Let us start with why is it important to organise your codebase and why “Just Organizing Your Folders” is not enough

Most beginner tutorials advocate a structure like:

src/
  components/
  pages/
  utils/
Enter fullscreen mode Exit fullscreen mode

This might work for a prototype. But in real apps:

  • Features span multiple concerns (UI, logic, state, API)
  • Teams need ownership and autonomy
  • Features evolve independently and at different paces
  • Technical debt snowballs unless carefully constrained

If you’re not careful, you’ll end up with:

  • Tight coupling between unrelated modules (which makes it harder to solve and make changes) as the codebase grows
  • Implicit dependencies ( becomes nightmare if the packages deprecate or drop support for older versions)
  • Massive components doing everything (actually defeats the whole purpose of modularity that react provides)

So let’s re-architect — the right way.

#1: Organize by Feature, Not File Type

Structure your app like the product, not the framework.

Example →

src/
  features/
    auth/
      components/
      pages/
      hooks/
      services/
      store/
      index.ts
    dashboard/
      widgets/
      services/
      store/
  shared/
    components/
    utils/
    theme/
  app/
    routes/
    App.tsx
    store.ts
Enter fullscreen mode Exit fullscreen mode

Early in development, it’s common to organize React projects by file type:

src/
  components/
  services/
  store/
  utils/
Enter fullscreen mode Exit fullscreen mode

At first glance, this feels clean — everything is in its “bucket.” But as the app grows, so does the complexity. Updating something like the cart feature means bouncing between components/, store/, services/, and utils/. This separation slows you down and creates mental overhead.

Instead, grouping code by feature solves this:

src/
  features/
    cart/
      components/
      services/
      store/
      utils/
Enter fullscreen mode Exit fullscreen mode

Now, everything related to the cart lives in one folder. You don’t need to mentally piece together the UI, API, and logic — it’s all scoped and colocated.

As your team scales, different squads might handle different domains: authentication, payments, dashboard, etc. With a file-type structure, teams often overlap and touch shared folders, which leads to conflicts and tight coupling. Each team owns everything inside their domain — components, API calls, state management, and styles. This clear boundary makes parallel development much easier. Teams can refactor their own features without worrying about breaking others.

Also , when features are self-contained, refactoring becomes safer. You can confidently update logic inside features/cart/ knowing it won’t affect unrelated features like auth or dashboard.

You can even write integration tests specific to a feature folder or migrate an entire module to a different project if needed.

#2: Separate Business Logic from Technical/UI Logic

This is often overlooked, but it’s a huge factor in keeping your code maintainable, scalable, and testable.

Business Logic = “What Should Happen?”

Business logic represents your domain knowledge — the core rules and processes that define how your product works. It’s not tied to how things are displayed; it’s about enforcing rules and driving decisions.

Some examples include:

  • Preventing checkout if the user’s email is unverified.
  • Calculating taxes or applying promotional discounts.
  • Determining which dashboard widgets are visible based on user roles.
  • Applying decision trees, workflows, or state machines.

This kind of logic should not live in your components. Instead, keep it in:

  • services/ – pure, testable functions
  • store/ – central state and reducers
  • useCases/ or logic/ folders – orchestrators or action handlers
  • A BFF (backend-for-frontend) layer when logic is shared across clients (although this requires a seperate blog for itself)

Example →

// features/cart/services/discountEngine.ts
export function applyDiscount(cart, user) {
  if (user.role === "admin") return cart.total;
  return cart.total * 0.9;
}
Enter fullscreen mode Exit fullscreen mode

Technical Logic = “How Should It Look or Behave?”

On the other hand, technical or UI logic governs the presentation layer. This includes how things feel and react to user input, such as:

  • Showing or hiding modals and dropdowns.
  • Managing local component state.
  • Triggering animations or transitions.
  • Debouncing user inputs or throttling events.

This logic is tied to interaction patterns, not product rules. It should live in:

  • React components (.tsx), In built Hooks
  • Custom UI hooks like useModal(), useDebounce()
  • Context providers for layout-specific state

Example →

// features/cart/components/CartSummary.tsx
const total = applyDiscount(cart, user); // business logic from service
return (
  <div>
    <h2>Total: ${total}</h2>
    {isLoading && <Spinner />}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Keep Your API Layer Centralized

One of the most common sources of technical debt in React projects is scattering API calls across multiple components. You’ve probably seen it — axios.get(...) hardcoded in the middle of a useEffect, mixed in with JSX and loading states. While it works for small apps, this approach becomes unmanageable as your app grows and the number of API interactions explodes.

Instead, you should centralize your API logic into clean, reusable service modules that live within their respective feature folders. This creates a single source of truth for all data-fetching logic, giving you a consistent and maintainable architecture.

Example →

// features/user/services/userAPI.ts
import { api } from "@/lib/api"; // abstracted axios/fetch wrapper
export const getUserProfile = () => api.get("/users/me");
export const updateUser = (data: any) => api.put("/users/me", data);
Enter fullscreen mode Exit fullscreen mode

This file is now the only place in your app that knows how to talk to /users/me. Any component or hook that needs user data simply calls getUserProfile(). That means:

  • If you switch from Axios to fetch, GraphQL, or gRPC, only this file changes.
  • If an endpoint changes, you update it in one place.
  • If you want to add caching, retry logic, or auth headers — again, one place.

By isolating your HTTP logic, you avoid repetitive code, prevent inconsistencies, and make your app significantly easier to test.

Shared API Clients and Interceptors

You should also wrap your HTTP client (like Axios or Fetch) in a shared utility that handles concerns like Authorization headers, Response formatting, Retry behavior, Error normalization

Example:

// lib/api.ts
import axios from "axios";
export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

api.interceptors.response.use(
  (res) => res,
  (err) => {
    // Handle auth expiration, logging, etc.
    if (err.response?.status === 401) {
      // Trigger logout or refresh flow
    }
    return Promise.reject(err);
  }
);
Enter fullscreen mode Exit fullscreen mode

This ensures every API call is consistent and secure, without repeating the same setup everywhere.

#3: Isolate State Locally, Share Sparingly

One of the most common mistakes in scaling React apps is assuming that all state needs to be global. New developers often reach for Redux (or Zustand, Jotai, etc.) too early — stuffing even the smallest UI toggle or input field into global state management. This quickly leads to a bloated, hard-to-reason-about store that slows down your app and frustrates you and your team later on.

But here’s the truth: most state should be local.

Ask yourself: “Does another component outside this one need to know about this state?” If the answer is no, it doesn’t belong in global state. It should live inside the component where it’s used — using useState, useReducer, or even useRef.

Local state is perfect for: Form inputs, Modal toggles, Tab selections, Pagination on a single page, Temporary view logic (e.g., dark mode toggle in the UI only)

There are legitimate cases for shared/global state. A few examples: User session data (auth tokens, roles, user info), Shopping cart (shared across multiple pages and components), Theme settings (e.g., light/dark mode), Cross-feature settings (e.g., a search filter that affects multiple modules)

In these cases, you need state that survives page transitions and can be accessed and updated from anywhere. This is where tools like Zustand, Redux Toolkit, or even React Context make sense (we will talk mopre about it in part two).

Aside from useState and a global state managment library, there are other ways to scope your state smartly:

  • React Context: Great for lightweight shared state (like theme or locale).
  • URL query parameters: Ideal for filters, sort orders, and pagination that should persist across reloads or be shareable via a link.

Tooling Saves You from Yourself

#4: Tooling Saves You from Yourself

A consistent architecture is easier to enforce than remember. I will leave this section short as it completely dependent on the personal taste and project type as to how to use and configure each of them. But as per my experience including this tools from early on in you project makes your’s and your’s team life easier later on.

Set up these with whatever config you like:

  • ESLint with rules for import order, hooks, unused vars
  • TypeScript to prevent miscommunication between layers
  • Prettier for formatting
  • Husky + lint-staged for pre-commit safety

#5: Testing Is a First-Class Citizen

In a production-grade React app, testing isn’t just a checkbox(but often time it is) — it’s a core part of your development workflow. The bigger your app grows, the more critical automated testing becomes to maintain quality, avoid regressions, and enable confident refactoring.

The key to effective testing is isolation. You can’t reliably test code that’s tightly coupled or tangled with UI, state, and side effects. Well-structured, modular code naturally leads to easier and faster tests.

As a rule of thumb

Unit tests should cover:

  • Pure functions (business logic, helpers)
  • Custom hooks (e.g., useUser(), useCart())
  • Redux or Zustand reducers and actions

Example →

import { applyDiscount } from "./discountEngine";
test("applies 10% discount for regular users", () => {
  const cart = { total: 100 };
  const user = { role: "user" };
  expect(applyDiscount(cart, user)).toBe(90);
});
Enter fullscreen mode Exit fullscreen mode

Integration Test shoul have

  • Render expected UI elements
  • Respond correctly to user events (clicks, typing)
  • Communicate properly with hooks or context

Example →

import { render, screen, fireEvent } from "@testing-library/react";
import LoginForm from "./LoginForm";
test("submits with valid email and password", () => {
  render(<LoginForm onSubmit={jest.fn()} />);
  fireEvent.change(screen.getByLabelText(/email/i), {
    target: { value: "user@example.com" },
  });
  fireEvent.change(screen.getByLabelText(/password/i), {
    target: { value: "password123" },
  });
  fireEvent.click(screen.getByRole("button", { name: /submit/i }));
  expect(screen.queryByText(/error/i)).toBeNull();
});
Enter fullscreen mode Exit fullscreen mode

E2E Testing: Simulate Real User Flows

  • Logging in and out
  • Adding products to cart
  • Completing checkout
  • Navigating through pages

For end-to-end tests, tools like Cypress or Playwright automate full workflows:

Example Cypress test for login:

describe("Login flow", () => {
  it("logs in successfully with valid credentials", () => {
    cy.visit("/login");
    cy.get('input[name="email"]').type("user@example.com");
    cy.get('input[name="password"]').type("password123");
    cy.get("button[type=submit]").click();
    cy.url().should("include", "/dashboard");
  });
});
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

There’s no perfect structure — only the one you evolve into intentionally.

The key isn’t folders or filenames. It’s about ownership, boundaries, and intentional separation of concerns. A scalable frontend architecture is one where Teams can work independently, Features can evolve safely Business logic doesn’t get trapped in the UI and personally most important of all refactoring doesn’t feel like surgery

Thank you for reading.

Top comments (0)