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/
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
Early in development, it’s common to organize React projects by file type:
src/
components/
services/
store/
utils/
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/
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/
orlogic/
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;
}
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>
);
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);
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);
}
);
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);
});
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();
});
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");
});
});
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)