I've seen this happen more times than I can count. A React app starts clean — components are small, state is local, everyone understands the code. Six months later, the same codebase is a maze. Components import from three directories up. State lives in seven different places. Adding a feature means touching twelve files. Nobody planned for it to go wrong. It just did.
The problem isn't React. React is deliberately flexible. It doesn't force structure on you, which is both why developers love it and why large teams struggle with it. Without a deliberate architecture, that flexibility turns into entropy.
This article covers the architectural decisions that actually matter in production React apps — folder structure, component design, state management, API abstraction, routing, and performance. By the end, you'll have a complete mental model for building React applications that stay maintainable as they grow.
The real cost of no architecture
Before diving into solutions, let me be direct about what poor architecture costs you.
A component that fetches data, handles form state, controls its own visibility, and renders UI is doing four jobs at once. When it breaks, you spend 20 minutes reading it before you understand what's wrong. When you need to reuse its data-fetching logic, you can't — it's buried inside a component that also handles UI. When you test it, you have to mock a server just to render a button.
That cost compounds. The longer poor structure persists, the more expensive it becomes to fix. Developers stop touching files they don't own. Features get duplicated because nobody knows where to put shared logic. Onboarding new engineers takes three weeks instead of three days.
Good architecture isn't about following rules for their own sake. It's about building a system where changes stay local, where you can reason about one thing at a time, and where the codebase doesn't punish you for growing.
Part 1: Folder structure
The folder-by-type mistake
Most React tutorials start here:
src/
components/
hooks/
utils/
pages/
services/
This works for small apps. It fails for large ones. When your components/ folder has 80 files in it, "find the user profile card" means searching through everything. When you delete a feature, you have to hunt across four folders to find all related files.
The root problem: folder-by-type organizes around file type, not around business domain. But code changes are triggered by business changes, not by technical categories.
Feature-based structure
The better approach organizes code around features — the parts of your application that users actually interact with.
src/
features/
auth/
components/
LoginForm.jsx
LoginForm.test.jsx
hooks/
useAuth.js
authSlice.js
authService.js
index.js
dashboard/
components/
hooks/
dashboardSlice.js
dashboardService.js
index.js
components/ ← shared UI components (Button, Modal, Input)
hooks/ ← shared hooks (useDebounce, usePagination)
services/ ← shared API config (axios instance, interceptors)
routes/
store/
Each feature owns its components, hooks, state slice, and service calls. The index.js acts as the feature's public API — anything not exported there is private to the feature.
When you need to work on authentication, you open features/auth/. When a feature is deleted, you delete one folder. When a new engineer asks where the dashboard state lives, the answer is obvious.
Shared code that's needed by more than one feature goes in the top-level components/, hooks/, or services/ directories. The rule is simple: if only one feature needs it, it lives inside that feature. If two features need it, it moves up.
Part 2: Component architecture
Three component types, not one
Every React component you write falls into one of three categories. Treating them the same is where most component complexity comes from.
Presentation components render UI based on props. They hold no state, make no API calls, and contain no business logic. A UserCard that receives a user object and renders a name and avatar is a presentation component. These are the easiest to test, the easiest to reuse, and the easiest to reason about.
// Presentation component — pure UI
const UserCard = ({ name, avatarUrl, role }) => (
<div className="user-card">
<img src={avatarUrl} alt={name} />
<h3>{name}</h3>
<span>{role}</span>
</div>
);
Container components handle data. They fetch from APIs, manage state, and pass data down to presentation components. They contain logic, but minimal rendering.
// Container component — data and logic
const UserCardContainer = ({ userId }) => {
const { data: user, isLoading } = useQuery(['user', userId], () => getUser(userId));
if (isLoading) return <Spinner />;
return <UserCard name={user.name} avatarUrl={user.avatar} role={user.role} />;
};
Generic reusable components are your design system — Button, Modal, Input, Dropdown. These live in the shared components/ directory. They accept props for every behavior they support, depend on nothing outside themselves, and never make API calls.
Keeping these three types separate isn't academic. When a UserCard starts fetching its own user data, you've lost the ability to test it without a network, reuse it with different data sources, or compose it inside a larger data-fetching component without triggering double fetches.
Atomic design (when you need it)
For teams building large design systems, Atomic Design adds useful vocabulary:
Atoms → Molecules → Organisms → Templates → Pages
Atoms are single elements: a button, an input, a label. Molecules combine atoms: a search form is an input + button. Organisms are complete UI sections: a navigation bar, a product card. Templates define page layouts. Pages are instances of templates with real data.
This system works well when multiple teams share a component library. For smaller apps, the three-type model above is simpler and sufficient.
Part 3: State management
The most common state mistake
Reaching for Redux on day one. This is incorrect.
Redux solves a real problem — sharing state across many disconnected components — but it solves it with significant overhead. You write action creators, reducers, selectors, and setup code before you can store a single value. For state that only one component needs, this is pure cost with no benefit.
Use the right tool for each type of state:
Local state (useState, useReducer) handles form inputs, UI toggles, modal visibility, accordion open/close. If the data is only relevant to one component, keep it there.
Server state (React Query / TanStack Query) handles data that comes from an API. This is the one most developers get wrong. Server state has unique properties — it lives on the server, it can become stale, it needs caching, it needs background refetch. Redux doesn't handle any of this by default. React Query does all of it.
// React Query handles caching, refetching, and loading states
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 5 * 60 * 1000, // consider fresh for 5 minutes
});
Global client state (Redux Toolkit, Zustand, Context) handles state that multiple components across the tree need simultaneously — authenticated user, theme, shopping cart, notifications. This is the correct use case for global state.
The decision rule:
One component needs it → useState
Comes from an API → React Query
Multiple disconnected components need it → Zustand or Redux Toolkit
Zustand vs Redux Toolkit
Redux Toolkit reduced Redux boilerplate significantly, but Zustand takes it further. For most apps that need global client state, Zustand is faster to set up and easier to read.
// Zustand store — minimal setup
import { create } from 'zustand';
const useAuthStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}));
Redux Toolkit makes more sense when: your team already knows Redux, you need DevTools time-travel debugging, or you're managing complex state with many interdependent slices. For greenfield projects with a small team, Zustand wins on simplicity.
Part 4: API and data layer
The service layer pattern
Components should never contain raw fetch or axios calls. Here's why: when your API URL changes, when you add auth headers, when you need to handle errors consistently, you want to make that change in one place — not across 40 components.
The service layer is that one place.
// utils/axios.js — configure once
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
});
// Add auth token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Handle errors in one place
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// redirect to login
}
return Promise.reject(error);
}
);
export default api;
// features/users/userService.js — thin API functions
import api from '../../utils/axios';
export const getUsers = () => api.get('/users');
export const getUserById = (id) => api.get(`/users/${id}`);
export const updateUser = (id, data) => api.put(`/users/${id}`, data);
// features/users/hooks/useUsers.js — React Query wraps service calls
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../userService';
export const useUsers = () => {
return useQuery({
queryKey: ['users'],
queryFn: getUsers,
});
};
The data flow is UI → Hook → Service → API. Each layer has one job. UI renders. Hooks coordinate. Services call the API. When something breaks, you know exactly where to look.
Part 5: Routing architecture
React Router v6 with nested layouts
Flat route lists don't scale. When you have 30 routes, a flat list becomes unreadable. Nested routes let you group related pages under shared layouts.
routes/
AppRoutes.jsx ← top-level route config
ProtectedRoute.jsx ← auth guard component
PublicRoute.jsx ← redirect logged-in users away
// routes/AppRoutes.jsx
const AppRoutes = () => (
<Routes>
{/* Public routes */}
<Route element={<PublicRoute />}>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Route>
{/* Protected routes with shared layout */}
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route path="/" element={<Home />} />
<Route path="/users" element={<Users />} />
<Route path="/users/:id" element={<UserDetail />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
// routes/ProtectedRoute.jsx
const ProtectedRoute = () => {
const { user } = useAuthStore();
return user ? <Outlet /> : <Navigate to="/login" replace />;
};
The ProtectedRoute and PublicRoute components handle authentication at the routing level. Components don't need to check auth state themselves. The DashboardLayout renders the sidebar and nav once — every nested route gets them automatically without re-rendering the shell.
Part 6: Hooks architecture
Custom hooks are the most underused tool in React. They're how you move logic out of components without needing a state management library.
The rule: if a component has more than two useState calls, or more than one useEffect, those should probably be a custom hook.
// hooks/useDebounce.js — reusable across features
import { useState, useEffect } from 'react';
export const useDebounce = (value, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
};
// features/users/hooks/useUserSearch.js — feature-specific hook
export const useUserSearch = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const { data: results, isLoading } = useQuery({
queryKey: ['users', 'search', debouncedQuery],
queryFn: () => searchUsers(debouncedQuery),
enabled: debouncedQuery.length > 0,
});
return { query, setQuery, results, isLoading };
};
The component that uses useUserSearch has no idea how debouncing works, when queries fire, or how results cache. It calls the hook and gets data. That's the right level of abstraction.
Avoid "god hooks" — hooks that do ten things because you didn't want to write two. A hook that handles search, pagination, sorting, and filtering is as hard to maintain as a component that does the same.
Part 7: Performance
When to optimize and when not to
Most React performance problems come from optimizing too early or in the wrong place.
React.memo prevents a component from re-rendering if its props haven't changed. Use it on components that render frequently, are expensive to render, and receive stable props. Don't wrap every component in memo — you add overhead and gain nothing most of the time.
// Only wrap when you've measured a real performance problem
const UserCard = React.memo(({ name, avatarUrl }) => (
<div>...</div>
));
useMemo and useCallback cache computed values and function references. The rule: use them when a child component is wrapped in memo and depends on the reference, or when a computation is genuinely expensive (measured, not assumed).
const filteredUsers = useMemo(
() => users.filter(user => user.active),
[users]
);
Code splitting with React.lazy
Bundle everything together and your initial load suffers. React.lazy with Suspense splits your code at the route level — users download only what they need.
const Dashboard = lazy(() => import('./features/dashboard/pages/Dashboard'));
const Settings = lazy(() => import('./features/settings/pages/Settings'));
const AppRoutes = () => (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
List virtualization
Rendering 1,000 list items creates 1,000 DOM nodes. Virtualization renders only what's visible.
import { FixedSizeList as List } from 'react-window';
const UserList = ({ users }) => (
<List
height={600}
itemCount={users.length}
itemSize={72}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<UserCard user={users[index]} />
</div>
)}
</List>
);
Use react-window for any list over 100 items. The performance difference is dramatic — users see smooth scrolling instead of frame drops.
Part 8: Error handling
Error boundaries
JavaScript errors inside components crash the whole tree by default. Error boundaries catch errors and show fallback UI instead.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
// Log to Sentry, LogRocket, etc.
console.error(error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <p>Something went wrong.</p>;
}
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<ErrorPage />}>
<Dashboard />
</ErrorBoundary>
Wrap top-level routes and feature sections in error boundaries. If the dashboard crashes, the nav still works.
Centralized API error handling
Handle network errors in the axios interceptor — 401 redirects, 500 error toasts, network timeouts. Don't scatter error handling logic across service functions.
React Query gives you error state out of the box for API errors that don't need central handling:
const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: getUsers });
if (error) return <ErrorMessage message={error.message} />;
Part 9: Testing architecture
What to test and where
Unit tests cover isolated logic — service functions, utility functions, custom hooks. Integration tests cover component behavior — does the form submit with correct data? Does the error state appear when the API fails? End-to-end tests cover user flows — can a user log in, navigate to the dashboard, and see their data?
src/
features/
auth/
components/
LoginForm.jsx
LoginForm.test.jsx ← integration test lives next to component
hooks/
useAuth.test.js ← hook test
authService.test.js ← service test
__tests__/
e2e/ ← Playwright or Cypress tests
// LoginForm.test.jsx — test behavior, not implementation
import { render, screen, userEvent } from '@testing-library/react';
import { QueryClientProvider } from '@tanstack/react-query';
import LoginForm from './LoginForm';
test('shows error when login fails', async () => {
server.use(
rest.post('/api/login', (req, res, ctx) => res(ctx.status(401)))
);
render(
<QueryClientProvider client={queryClient}>
<LoginForm />
</QueryClientProvider>
);
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'wrongpassword');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});
Test what users experience, not internal implementation. If you're asserting on component state directly, you're testing the wrong thing.
Part 10: The complete architecture picture
Here's how all the layers connect:
UI Layer (Pages, Components)
↓
Hooks Layer (useAuth, useUsers, useSearch)
↓
Service Layer (userService, authService)
↓
API Layer (configured axios instance)
↓
Backend / External APIs
↓
Cache Layer (React Query cache)
↑
State Layer (Zustand for global client state)
Data flows down from the service layer through hooks into components. User actions flow up from components through hooks to service calls. Global state sits beside this flow — components read from Zustand stores directly when they need cross-cutting data like the current user.
The folder structure maps directly to this:
src/
features/ ← business domain code (feature-scoped)
auth/
dashboard/
users/
components/ ← shared UI (presentational only)
hooks/ ← shared logic
services/ ← shared API config
routes/ ← routing
store/ ← global state config
What to do next
Don't try to refactor an existing codebase to this architecture all at once. That approach fails every time. Instead:
Pick the most painful feature in your app — the one developers avoid touching. Migrate that feature to the feature-based structure. Add a service layer for its API calls. Move its state to React Query where it makes sense. Write tests for the pieces you move.
Once it works and the team sees the difference, migration becomes pull-request-by-pull-request over months instead of a risky rewrite.
The architecture described here isn't theory. It's the pattern that survives contact with real teams, real deadlines, and real codebases that need to grow. The sooner you introduce structure, the lower the cost. The longer you wait, the more expensive the fix.
Start with the next feature you build.
Top comments (0)