Writing React components is relatively easy.
Building a React application that remains clean, maintainable, testable, and scalable as it grows is a different challenge.
At the beginning of a project, you might have only a few components:
src/
├── App.jsx
├── Header.jsx
├── Footer.jsx
└── main.jsx
This structure works perfectly well for a small application.
But what happens when your project grows to include:
- Dozens or hundreds of components.
- Authentication and authorization.
- Multiple API integrations.
- Complex forms.
- Shared application state.
- Reusable UI elements.
- Different business features.
- Automated tests.
- Multiple developers working together.
Without a clear architecture, components become difficult to understand, files become difficult to find, and changing one feature can unexpectedly break another.
In this article, we'll explore practical React architecture principles and learn how to organize applications for long-term maintainability.
We'll cover:
- Component architecture.
- Project folder structures.
- Separation of concerns.
- Reusable components.
- Composition patterns.
- Custom Hooks.
- Context and state management.
- API service layers.
- Error boundaries.
- Environment variables.
- Performance and maintainability.
- A practical project architecture.
Let's get started.
1. What Is React Architecture?
React architecture describes how you organize the components, logic, data, and responsibilities of your application.
It answers questions such as:
- Where should components live?
- Where should API requests be implemented?
- How should shared state be managed?
- How should components communicate?
- How should business logic be separated from UI?
- How should the application grow without becoming difficult to maintain?
React does not force you to use one specific architecture.
Instead, it provides flexible building blocks that you can organize according to your application's needs.
The goal is not to create the most complicated folder structure.
The goal is to make your code:
- Easy to understand.
- Easy to change.
- Easy to test.
- Easy to reuse.
- Easy to extend.
A good architecture should reduce complexity rather than introduce unnecessary abstractions.
2. Understanding Component Architecture
Components are the foundation of React applications.
A component should ideally have a clear responsibility.
Consider this example:
function UserDashboard() {
return (
<div>
<header>
<h1>Dashboard</h1>
</header>
<div>
<h2>User Information</h2>
<p>Alex</p>
</div>
<div>
<h2>Statistics</h2>
<p>120 Projects</p>
</div>
</div>
);
}
This component works, but it combines several UI responsibilities.
As the application grows, it becomes harder to maintain.
A better approach is to split the interface into smaller components.
function Header() {
return (
<header>
<h1>Dashboard</h1>
</header>
);
}
function UserInfo() {
return (
<section>
<h2>User Information</h2>
<p>Alex</p>
</section>
);
}
function Statistics() {
return (
<section>
<h2>Statistics</h2>
<p>120 Projects</p>
</section>
);
}
function UserDashboard() {
return (
<div>
<Header />
<UserInfo />
<Statistics />
</div>
);
}
Now each component has a more focused responsibility.
Why Smaller Components Matter
Smaller components offer several benefits:
- Easier debugging.
- Better reusability.
- Simpler testing.
- Clearer code organization.
- More manageable updates.
However, don't split every HTML element into its own component.
A component should represent a meaningful unit of UI or behavior.
For example, creating a component for a single static span may add unnecessary complexity.
Best practice: Split components when doing so improves clarity, reuse, or independent behavior.
3. Container and Presentation Components
A useful architectural concept is separating data-related responsibilities from visual presentation.
You may encounter two common component roles:
- Container components.
- Presentation components.
Presentation Components
Presentation components focus primarily on displaying data.
They receive information through props and render UI.
function UserCard({ user }) {
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
);
}
This component doesn't need to know where the user data came from.
It simply displays the information it receives.
Container Components
Container components coordinate data and application behavior.
For example:
import { useEffect, useState } from "react";
import UserCard from "./UserCard";
function UserContainer() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch("/api/user");
if (!response.ok) {
throw new Error("Failed to fetch user");
}
const data = await response.json();
setUser(data);
} finally {
setLoading(false);
}
}
fetchUser();
}, []);
if (loading) {
return <p>Loading...</p>;
}
if (!user) {
return <p>User not found.</p>;
}
return <UserCard user={user} />;
}
Here:
-
UserContainermanages data loading. -
UserCarddisplays the user.
Is This Pattern Still Useful?
Yes, but it is not a mandatory architecture.
Modern React applications often use Custom Hooks, framework data-loading features, and data-fetching libraries to separate responsibilities.
You don't need to create a container component for every presentation component.
The important principle is separating responsibilities when it makes the code easier to maintain.
4. Organizing a React Project
One of the first architectural decisions is choosing a folder structure.
There is no universally perfect structure.
Let's explore three common approaches.
Approach 1: Organize by File Type
src/
├── components/
├── hooks/
├── services/
├── context/
├── pages/
├── utils/
└── App.jsx
This structure groups files according to their technical role.
For example:
- All components live in
components. - All Custom Hooks live in
hooks. - API functions live in
services.
It's simple and works well for small applications.
However, as the project grows, related files can become scattered across multiple folders.
Approach 2: Organize by Feature
A feature-based structure groups files according to application functionality.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── index.js
│ │
│ ├── users/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── index.js
│ │
│ └── products/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── index.js
│
├── shared/
│ ├── components/
│ ├── hooks/
│ └── utils/
│
├── app/
│ ├── App.jsx
│ └── providers.jsx
│
└── main.jsx
Now everything related to authentication lives inside the auth feature.
Everything related to products lives inside products.
This can make large applications easier to navigate.
Approach 3: Hybrid Structure
A hybrid structure combines feature-based organization with shared technical folders.
src/
├── app/
│ ├── App.jsx
│ ├── router.jsx
│ └── providers.jsx
│
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types.js
│ │
│ ├── dashboard/
│ │ ├── components/
│ │ └── hooks/
│ │
│ └── products/
│ ├── components/
│ ├── hooks/
│ └── services/
│
├── shared/
│ ├── components/
│ ├── hooks/
│ ├── utils/
│ └── constants/
│
├── services/
│ └── apiClient.js
│
└── main.jsx
This is a practical starting point for many medium-sized applications.
Which Structure Should You Choose?
| Project size | Suggested approach |
|---|---|
| Small application | Simple file-type structure |
| Medium application | Hybrid structure |
| Large application | Feature-based structure |
| Complex multi-team application | Feature-based structure with explicit module boundaries |
These are guidelines, not strict rules.
Start simple and introduce more structure when the project needs it.
5. Separation of Concerns
Separation of concerns means keeping different responsibilities in appropriate places.
Consider a component that handles everything:
function Products() {
// Fetch products
// Validate data
// Filter products
// Handle user interactions
// Manage loading state
// Render product cards
// Format prices
// Handle errors
return <div>...</div>;
}
This approach can become difficult to maintain.
Instead, separate the responsibilities.
For example:
features/products/
├── components/
│ ├── ProductList.jsx
│ └── ProductCard.jsx
│
├── hooks/
│ └── useProducts.js
│
├── services/
│ └── productService.js
│
└── utils/
└── formatPrice.js
Each module has a specific purpose.
Example: Price Formatting
Instead of repeating price formatting logic in multiple components:
function formatPrice(price) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD"
}).format(price);
}
export default formatPrice;
Use it wherever needed:
import formatPrice from "../utils/formatPrice";
function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{formatPrice(product.price)}</p>
</article>
);
}
Now the formatting logic exists in one place.
If the formatting rules change, you only need to update one function.
6. Reusable Components
One of React's greatest strengths is component reuse.
Instead of creating separate buttons with slightly different markup, build a reusable component.
Example: Button Component
function Button({
children,
variant = "primary",
disabled = false,
onClick
}) {
return (
<button
className={`button button-${variant}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
export default Button;
Usage:
function App() {
return (
<div>
<Button variant="primary">
Save
</Button>
<Button variant="secondary">
Cancel
</Button>
<Button variant="danger">
Delete
</Button>
</div>
);
}
Why Use children?
The children prop allows a component to accept arbitrary nested content.
For example:
<Button>
Save Changes
</Button>
Or:
<Button>
<span>Download</span>
</Button>
This makes reusable components more flexible.
Don't Over-Abstract
Not every repeated line of JSX needs a reusable component.
A good abstraction should provide meaningful benefits.
For example, a reusable button makes sense because buttons often share:
- Styling.
- Accessibility behavior.
- Disabled states.
- Loading behavior.
- Interaction patterns.
A component that only wraps one static element without adding useful behavior may not be necessary.
7. Component Composition
Composition is one of the most important React design principles.
Instead of creating one component that handles every possible configuration, create smaller components that can be combined.
Example: Card Component
function Card({ children }) {
return (
<div className="card">
{children}
</div>
);
}
Now create a specialized card:
function UserProfile() {
return (
<Card>
<h2>Alex Morgan</h2>
<p>Frontend Developer</p>
<button>View Profile</button>
</Card>
);
}
Another component can reuse the same card:
function ProductCard() {
return (
<Card>
<h2>Mechanical Keyboard</h2>
<p>$99</p>
<button>Buy Now</button>
</Card>
);
}
The Card component doesn't need to know whether it contains a user profile or a product.
It simply provides a layout container.
Named Slots Through Props
You can also create components that accept multiple content regions.
function PageLayout({
header,
sidebar,
children,
footer
}) {
return (
<div className="page-layout">
<header>{header}</header>
<div className="page-content">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}
Usage:
function Dashboard() {
return (
<PageLayout
header={<h1>Dashboard</h1>}
sidebar={<nav>Navigation</nav>}
footer={<p>Copyright 2026</p>}
>
<h2>Welcome!</h2>
<p>Your dashboard content goes here.</p>
</PageLayout>
);
}
This is a flexible alternative to building one enormous component with many conditional branches.
8. Custom Hooks for Reusable Logic
Custom Hooks allow you to extract stateful behavior from components.
Suppose multiple components need to track whether a user is online.
Instead of repeating the same Effect, create a reusable Hook.
import { useEffect, useState } from "react";
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
navigator.onLine
);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
export default useOnlineStatus;
Now use it in different components:
function NetworkIndicator() {
const isOnline = useOnlineStatus();
return (
<p>
Status: {isOnline ? "Online" : "Offline"}
</p>
);
}
Another component can use the same Hook:
function SaveButton() {
const isOnline = useOnlineStatus();
return (
<button disabled={!isOnline}>
Save Changes
</button>
);
}
Each component gets its own Hook state, while the logic is reused.
When Should You Create a Custom Hook?
Consider creating one when:
- Multiple components share stateful behavior.
- A component contains a complicated Effect.
- You want to separate behavior from presentation.
- You need a reusable browser integration.
- You want to make component logic easier to test.
Avoid creating a Custom Hook simply to move a few unrelated lines into another file.
9. Managing Shared State
As an application grows, multiple components may need access to the same state.
For example:
- Current authenticated user.
- Shopping cart.
- Selected language.
- Theme preferences.
- Application notifications.
There are several ways to manage shared state.
Option 1: Lift State Up
If two components need the same state, move it to their closest common parent.
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return (
<div>
<CounterDisplay count={count} />
<CounterControls
count={count}
setCount={setCount}
/>
</div>
);
}
function CounterDisplay({ count }) {
return <h2>Count: {count}</h2>;
}
function CounterControls({ count, setCount }) {
return (
<button onClick={() => setCount(count + 1)}>
Increase
</button>
);
}
This works well when components are relatively close in the tree.
Option 2: Context API
Context allows you to share values with deeply nested components.
import {
createContext,
useContext,
useState
} from "react";
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = {
theme,
toggleTheme() {
setTheme(current =>
current === "light" ? "dark" : "light"
);
}
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error(
"useTheme must be used within ThemeProvider"
);
}
return context;
}
Usage:
function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
Option 3: Dedicated State Management
For larger applications, you may need a more specialized state-management solution.
Common options include:
- Redux Toolkit.
- Zustand.
- Jotai.
- MobX.
Each has a different approach.
For example, Zustand provides a relatively simple store API.
Install it:
npm install zustand
Create a store:
import { create } from "zustand";
const useCounterStore = create(set => ({
count: 0,
increment: () =>
set(state => ({
count: state.count + 1
})),
reset: () =>
set({ count: 0 })
}));
export default useCounterStore;
Use it in a component:
import useCounterStore from "./counterStore";
function Counter() {
const count = useCounterStore(state => state.count);
const increment = useCounterStore(
state => state.increment
);
return (
<button onClick={increment}>
Count: {count}
</button>
);
}
Which State Management Approach Should You Choose?
| Situation | Recommended approach |
|---|---|
| State used by one component | useState |
| Complex local state transitions | useReducer |
| Shared state in a nearby component tree | Lift state up |
| Shared values across a component tree | Context |
| Complex global client state | Consider a state library |
| Remote API data and caching | Consider TanStack Query |
A useful distinction:
Client state represents information managed by your application, such as an open modal or selected tab.
Server state represents information that originates from an external server, such as users, products, or orders.
These types of state often benefit from different tools.
10. Separating API Logic into Service Layers
A common mistake is placing all HTTP requests directly inside UI components.
For example:
function Products() {
// Component state
// Fetch request
// Request headers
// Error handling
// Data transformation
// Rendering
return <div>...</div>;
}
This mixes several responsibilities.
A service layer provides a cleaner alternative.
Suggested Structure
features/
└── products/
├── components/
│ ├── ProductList.jsx
│ └── ProductCard.jsx
│
├── hooks/
│ └── useProducts.js
│
└── services/
└── productService.js
Create a Service
productService.js
const API_URL = "/api/products";
export async function getProducts(signal) {
const response = await fetch(API_URL, { signal });
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
export async function getProductById(id, signal) {
const response = await fetch(
`${API_URL}/${id}`,
{ signal }
);
if (!response.ok) {
throw new Error("Product not found");
}
return response.json();
}
export async function createProduct(product) {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(product)
});
if (!response.ok) {
throw new Error("Failed to create product");
}
return response.json();
}
Use the Service in a Custom Hook
useProducts.js
import { useEffect, useState } from "react";
import { getProducts } from "../services/productService";
export function useProducts() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
setLoading(true);
setError("");
const data = await getProducts(controller.signal);
setProducts(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadProducts();
return () => controller.abort();
}, []);
return {
products,
loading,
error
};
}
Use It in a Component
ProductList.jsx
import { useProducts } from "../hooks/useProducts";
import ProductCard from "./ProductCard";
function ProductList() {
const {
products,
loading,
error
} = useProducts();
if (loading) {
return <p>Loading products...</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<div>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
);
}
export default ProductList;
The component is now focused primarily on rendering.
The API request lives in a service, and reusable asynchronous behavior lives in a Custom Hook.
11. Error Boundaries
React applications need a strategy for handling unexpected rendering errors.
An Error Boundary is a component that catches errors thrown while rendering its descendant component tree and displays fallback UI.
This helps prevent an error in one part of the interface from breaking the entire application.
Creating an Error Boundary
Error Boundaries are traditionally implemented using class components.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false
};
}
static getDerivedStateFromError() {
return {
hasError: true
};
}
componentDidCatch(error, info) {
console.error("Application error:", error);
console.error("Component stack:", info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Something went wrong.</h2>
<p>Please try again later.</p>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Using the Error Boundary
import ErrorBoundary from "./ErrorBoundary";
import Dashboard from "./Dashboard";
function App() {
return (
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
);
}
You can also place Error Boundaries around individual application sections.
function App() {
return (
<div>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
<Footer />
</div>
);
}
If the dashboard throws a rendering error, the rest of the application can remain available.
Important Limitations
Error Boundaries do not automatically catch every kind of error.
They generally do not catch:
- Errors in event handlers.
- Errors in asynchronous callbacks.
- Errors in server-side rendering.
- Errors thrown by the Error Boundary itself.
For API failures, continue using normal try / catch handling and appropriate loading and error states.
12. Environment Variables
Applications often need configuration values such as:
- API base URLs.
- Feature flags.
- Public service identifiers.
- Environment-specific settings.
You shouldn't hardcode configuration throughout your application.
Instead, use environment variables.
Example with Vite
Create a .env file:
VITE_API_URL=https://api.example.com
Access the value:
const API_URL = import.meta.env.VITE_API_URL;
export default API_URL;
Use it in your service:
const API_URL = import.meta.env.VITE_API_URL;
export async function getProducts() {
const response = await fetch(`${API_URL}/products`);
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
Different Environments
You can create different configuration files:
.env
.env.development
.env.production
For example:
.env.development
VITE_API_URL=http://localhost:3000
.env.production
VITE_API_URL=https://api.example.com
Vite loads environment-specific files according to its current mode.
Important Security Rule
Environment variables exposed through frontend build tools are not secret.
For example, Vite exposes variables prefixed with VITE_ to browser code.
That means users can inspect them.
Never put these values in frontend environment variables:
- Database passwords.
- Private API keys.
- Secret tokens.
- Backend credentials.
- Private signing keys.
Keep sensitive credentials on the server.
13. Avoiding Prop Drilling
Prop drilling happens when data is passed through several intermediate components that don't actually need it.
Imagine this structure:
App
└── Dashboard
└── Layout
└── Sidebar
└── UserProfile
Suppose App owns the current user, but only UserProfile needs it.
Without Context, you might pass the user through every intermediate component.
function App() {
const user = {
name: "Alex"
};
return <Dashboard user={user} />;
}
function Dashboard({ user }) {
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserProfile user={user} />;
}
function UserProfile({ user }) {
return <h2>{user.name}</h2>;
}
This can become inconvenient when the component tree is deep.
Context offers another approach:
import {
createContext,
useContext
} from "react";
const UserContext = createContext(null);
function UserProfile() {
const user = useContext(UserContext);
return <h2>{user.name}</h2>;
}
function App() {
const user = {
name: "Alex"
};
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
Now UserProfile can access the value directly.
However, prop drilling is not always bad.
Passing props explicitly can make dependencies easier to understand.
Use Context when it genuinely simplifies the component tree.
14. Avoiding Unnecessary Re-renders
React components can render again when their state, props, or consumed context values change.
Sometimes this is necessary.
Sometimes you can reduce unnecessary work.
React provides several tools for optimization.
React.memo
memo can skip rendering a component when its props remain unchanged.
import { memo } from "react";
const UserCard = memo(function UserCard({ user }) {
console.log("UserCard rendered");
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
});
export default UserCard;
useMemo
Use useMemo to cache an expensive calculation when appropriate.
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.name.includes(search)
);
}, [products, search]);
useCallback
Use useCallback when stable function identity is useful.
const handleClick = useCallback(() => {
console.log("Clicked");
}, []);
Important: Don't Optimize Prematurely
Not every component needs memo.
Not every function needs useCallback.
Not every calculation needs useMemo.
Start with clear and correct code.
Use React DevTools Profiler and actual performance measurements to identify bottlenecks.
In projects using React Compiler, some manual memoization may be unnecessary depending on the compiler configuration.
15. A Practical Architecture: Building a Product Management Application
Now let's combine the ideas into a realistic project structure.
Imagine we're building a product management application.
It needs:
- Product listing.
- Product details.
- Product creation.
- Authentication.
- Shared navigation.
- API integration.
- Reusable UI components.
A possible structure:
src/
│
├── app/
│ ├── App.jsx
│ ├── router.jsx
│ └── providers.jsx
│
├── features/
│ │
│ ├── auth/
│ │ ├── components/
│ │ │ └── LoginForm.jsx
│ │ ├── hooks/
│ │ │ └── useAuth.js
│ │ └── services/
│ │ └── authService.js
│ │
│ └── products/
│ ├── components/
│ │ ├── ProductList.jsx
│ │ ├── ProductCard.jsx
│ │ └── ProductForm.jsx
│ │
│ ├── hooks/
│ │ └── useProducts.js
│ │
│ ├── services/
│ │ └── productService.js
│ │
│ └── utils/
│ └── formatPrice.js
│
├── shared/
│ ├── components/
│ │ ├── Button.jsx
│ │ ├── Input.jsx
│ │ ├── Modal.jsx
│ │ └── Spinner.jsx
│ │
│ ├── hooks/
│ │ └── useOnlineStatus.js
│ │
│ └── utils/
│ └── formatDate.js
│
├── services/
│ └── apiClient.js
│
├── styles/
│ └── global.css
│
└── main.jsx
This structure separates the application into meaningful areas.
Understanding the Main Folders
app/
Contains application-level configuration, routing, and providers.
features/
Contains functionality organized around business features.
shared/
Contains reusable components, Hooks, and utilities that are not specific to one feature.
services/
Contains shared infrastructure for communicating with external systems.
styles/
Contains global styling and shared design rules.
Example: Shared API Client
services/apiClient.js
const API_URL = import.meta.env.VITE_API_URL;
export async function apiRequest(path, options = {}) {
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers || {})
}
});
if (!response.ok) {
throw new Error(
`Request failed with status ${response.status}`
);
}
if (response.status === 204) {
return null;
}
return response.json();
}
Now feature services can reuse it.
features/products/services/productService.js
import { apiRequest } from "../../../services/apiClient";
export function getProducts(signal) {
return apiRequest("/products", { signal });
}
export function createProduct(product) {
return apiRequest("/products", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(product)
});
}
This is a simple example. A production API client may also need authentication handling, request timeouts, structured error parsing, and retry policies.
16. Testing and Architecture
Good architecture makes testing easier.
When responsibilities are separated, you can test each part independently.
For example:
- Utility functions can be tested without rendering React.
- Components can be tested with React Testing Library.
- API services can be tested with mocked network requests.
- Custom Hooks can be tested through a component or a Hook testing utility.
- Important user workflows can be tested end to end.
Consider a utility function:
export function calculateTotal(price, quantity) {
return price * quantity;
}
It can be tested independently:
import { calculateTotal } from "./calculateTotal";
test("calculates the total price", () => {
expect(calculateTotal(10, 3)).toBe(30);
});
The less tightly coupled your code is, the easier it usually becomes to test.
We'll explore testing in detail in Part 10 of this series.
17. Common React Architecture Mistakes
Let's review some mistakes that can make applications difficult to maintain.
Mistake 1: One Giant Component
A component that handles routing, API requests, validation, business logic, and rendering becomes difficult to understand.
Better approach: Split responsibilities into smaller components, Hooks, and services when appropriate.
Mistake 2: Over-Engineering
Creating dozens of folders and abstractions for a tiny application adds unnecessary complexity.
Better approach: Start simple and introduce abstractions when they solve a real problem.
Mistake 3: Duplicating Logic
Copying the same formatting, validation, or data-processing logic into multiple components creates maintenance problems.
Better approach: Extract genuinely reusable logic into utilities, components, or Custom Hooks.
Mistake 4: Putting Everything in Context
Context is useful, but using one enormous Context for every application value can cause unnecessary complexity and broad updates.
Better approach: Keep state close to where it is needed and separate unrelated contexts when appropriate.
Mistake 5: Mixing API Requests with UI Logic
Large components filled with request configuration and response handling are difficult to maintain.
Better approach: Consider service modules and Custom Hooks.
Mistake 6: Creating Too Many Generic Components
A highly configurable component with dozens of props can become harder to use than several simpler components.
Better approach: Prefer clear, focused APIs.
Mistake 7: Ignoring Error Handling
A production application needs strategies for network failures, invalid responses, and unexpected rendering errors.
Better approach: Handle errors at the appropriate layer.
Mistake 8: Sharing Everything Globally
Not every piece of state belongs in a global store.
Better approach: Keep local state local whenever possible.
Mistake 9: Treating Folder Structure as Architecture
Having folders named components, hooks, and services doesn't automatically make an application well-architected.
Architecture is about boundaries, responsibilities, dependencies, and communication between parts of the application.
18. A Practical Checklist for Clean React Architecture
Before considering a React feature complete, ask yourself:
Component design
- Does each component have a clear responsibility?
- Are reusable components genuinely reusable?
- Is component composition used where appropriate?
State management
- Is state stored close to where it is needed?
- Is shared state actually shared only when necessary?
- Is server state handled appropriately?
Code organization
- Are feature-related files grouped logically?
- Is API logic separated from UI?
- Are utilities reusable and independent?
Reliability
- Are loading and error states handled?
- Are important rendering errors covered by Error Boundaries?
- Are asynchronous operations cleaned up where appropriate?
Security
- Are secrets kept out of frontend code?
- Is server-side validation implemented?
- Are permissions enforced on the backend?
Maintainability
- Can another developer understand the structure?
- Are important behaviors testable?
- Are abstractions reducing complexity rather than increasing it?
19. Final Thoughts
Building a React application is not just about writing components.
It's about designing a system where components, state, services, and application features work together in a predictable way.
The most important principles from this article are:
- Keep components focused.
- Organize code around meaningful responsibilities.
- Use composition to build flexible interfaces.
- Extract reusable behavior into Custom Hooks.
- Keep API logic separate from presentation.
- Choose state-management tools based on actual requirements.
- Handle errors and loading states intentionally.
- Avoid unnecessary abstractions.
- Optimize only when there is a measurable reason.
- Design for maintainability, not just immediate functionality.
Remember:
Good architecture is not about writing more code or creating more folders. It's about making change easier.
Start with a simple structure, understand your application's needs, and evolve the architecture as the project grows.
What's Next?
In Part 6 of the React Mastery Series, we'll explore:
React Performance Optimization — Making Applications Faster and More Responsive
We'll cover:
- Understanding React rendering.
- Why components re-render.
- React.memo.
- useMemo and useCallback.
- Lazy loading.
- Code splitting.
- Suspense.
- List virtualization.
- Avoiding unnecessary Effects.
- React DevTools Profiler.
- Practical performance optimization techniques.
We'll learn how to identify real performance problems and fix them without making our code unnecessarily complicated.
Happy coding! 🚀
Top comments (0)