Let’s talk about building React applications that don’t fall apart as they grow. When I first started with React, I built everything in one giant component. It worked at first, but then adding a simple feature meant untangling a mess. Over time, I learned that certain structures, or patterns, help keep code organized and manageable.
The first and most important idea is breaking your interface into small, reusable pieces called components. Think of it like building with LEGO bricks instead of carving from a single block of wood.
Here’s a simple way to start. Separate components that do things from components that show things. I call the former “container” components and the latter “presentational” components. The container worries about data and logic. The presentational component only cares about how it looks.
// This component only shows a button. It doesn't know where the data comes from.
function UserCard({ name, email, avatarUrl }) {
return (
<div className="user-card">
<img src={avatarUrl} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
</div>
);
}
// This component manages the data and state, then passes it down.
function UserListContainer() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadUsers = async () => {
setIsLoading(true);
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
setIsLoading(false);
};
loadUsers();
}, []);
if (isLoading) return <p>Loading users...</p>;
return (
<div>
<h2>Our Team</h2>
<div className="user-grid">
{users.map(user => (
<UserCard
key={user.id}
name={user.name}
email={user.email}
avatarUrl={user.avatar}
/>
))}
</div>
</div>
);
}
This split makes life easier. I can change how the UserCard looks without touching any data-fetching logic. I can also test the UserCard in isolation by just passing it dummy data.
Now, what if I find myself writing the same data-fetching logic in multiple container components? This is where custom hooks come in. A custom hook lets me extract that repetitive logic into a sharable function.
I remember getting tired of writing useState and useEffect for every API call. So I made a reusable hook.
// A custom hook for fetching data from any URL
function useApiData(initialUrl, initialData = null) {
const [url, setUrl] = useState(initialUrl);
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
let isActive = true; // To prevent state updates on unmounted components
const fetchData = async () => {
setIsLoading(true);
setHasError(false);
try {
const response = await fetch(url);
const result = await response.json();
if (isActive) {
setData(result);
}
} catch (err) {
if (isActive) {
setHasError(true);
console.error('Fetch failed:', err);
}
} finally {
if (isActive) {
setIsLoading(false);
}
}
};
fetchData();
return () => {
isActive = false; // Cleanup function
};
}, [url]); // Re-run only if the URL changes
// A function to manually refetch, maybe with a new URL
const refetch = (newUrl) => {
if (newUrl) {
setUrl(newUrl);
} else {
// Force a re-fetch of the current URL by updating a dummy state
setUrl(prevUrl => prevUrl + '');
}
};
return { data, isLoading, hasError, refetch };
}
// Using the hook is now clean and simple
function ProductPage({ productId }) {
const { data: product, isLoading, hasError } = useApiData(`/api/products/${productId}`);
if (isLoading) return <Spinner />;
if (hasError) return <ErrorMessage message="Could not load product." />;
if (!product) return <p>No product found.</p>;
return (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
<p>{product.description}</p>
</div>
);
}
Custom hooks are powerful. I’ve made hooks for handling form state, managing timers, and connecting to web sockets. They keep my components clean and focused.
Sometimes, you need to share not just logic, but also a piece of the UI and its behavior. An older but still useful pattern for this is the “render prop.” A component with a render prop takes a function that returns React elements. It then calls that function with the data it manages.
It looks a bit strange at first, but it’s very flexible.
// A component that handles mouse tracking and lets YOU decide what to render
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (event) => {
setPosition({
x: event.clientX,
y: event.clientY
});
};
return (
<div style={{ height: '100vh' }} onMouseMove={handleMouseMove}>
{/* Call the 'render' function with the current position */}
{render(position)}
</div>
);
}
// Using the MouseTracker. I can decide to render a dot, coordinates, anything.
function App() {
return (
<div>
<h1>Move your mouse around</h1>
<MouseTracker
render={({ x, y }) => (
<div>
<p>The mouse is at ({x}, {y})</p>
<div
style={{
position: 'absolute',
left: x - 10,
top: y - 10,
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: 'blue'
}}
/>
</div>
)}
/>
</div>
);
}
The render prop pattern puts the rendering control back in your hands. The MouseTracker component handles the logic, but you have complete freedom over the UI.
Another classic pattern is the Higher-Order Component, or HOC. It’s a function that takes a component and returns a new, enhanced component. It’s like wrapping your component with extra functionality.
I often use HOCs for cross-cutting concerns—things many components need, like checking if a user is logged in.
// A HOC that adds authentication checks
function requireAuth(ComponentToProtect) {
function AuthenticatedComponent(props) {
const [authStatus, setAuthStatus] = useState({ checking: true, isAuthenticated: false });
useEffect(() => {
// Simulate checking a token
const token = localStorage.getItem('userToken');
if (token) {
// In reality, you'd validate this token with your backend
setAuthStatus({ checking: false, isAuthenticated: true });
} else {
setAuthStatus({ checking: false, isAuthenticated: false });
}
}, []);
if (authStatus.checking) {
return <div>Checking your login...</div>;
}
if (!authStatus.isAuthenticated) {
// Redirect or show a login prompt
return (
<div>
<p>You need to log in to see this page.</p>
<button onClick={() => window.location.href = '/login'}>Go to Login</button>
</div>
);
}
// If authenticated, render the original component
return <ComponentToProtect {...props} />;
}
// Give the new component a useful display name for React DevTools
const componentName = ComponentToProtect.displayName || ComponentToProtect.name || 'Component';
AuthenticatedComponent.displayName = `requireAuth(${componentName})`;
return AuthenticatedComponent;
}
// A normal page component
function SettingsPage() {
return <h1>Your Private Settings</h1>;
}
// The protected version, wrapped by the HOC
const ProtectedSettingsPage = requireAuth(SettingsPage);
// In my app router, I'd use ProtectedSettingsPage instead of SettingsPage
HOCs can feel a bit abstract, but they’re great for cleanly adding features without modifying the original component’s code.
For building complex UI pieces like a dropdown, a menu, or a set of tabs, I love the Compound Components pattern. This is where a group of components work together seamlessly, sharing state behind the scenes.
The magic here is React’s Context API, which lets you pass data through the component tree without manually threading props down every level.
// 1. Create a Context to share state
const AccordionContext = createContext();
// 2. The main parent component
function Accordion({ children, defaultOpenId = null }) {
const [openItemId, setOpenItemId] = useState(defaultOpenId);
const toggleItem = (id) => {
setOpenItemId(prevId => (prevId === id ? null : id));
};
const value = { openItemId, toggleItem };
return (
<AccordionContext.Provider value={value}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
// 3. A sub-component for the clickable header
function AccordionItem({ id, children }) {
const { openItemId, toggleItem } = useContext(AccordionContext);
const isOpen = openItemId === id;
return (
<div className="accordion-item">
<button
className="accordion-header"
onClick={() => toggleItem(id)}
aria-expanded={isOpen}
>
{children}
</button>
</div>
);
}
// 4. A sub-component for the collapsible content
function AccordionContent({ id, children }) {
const { openItemId } = useContext(AccordionContext);
const isOpen = openItemId === id;
if (!isOpen) return null;
return (
<div className="accordion-content">
{children}
</div>
);
}
// 5. Usage - it reads almost like plain HTML
function HelpPage() {
return (
<Accordion defaultOpenId="q1">
<h2>Frequently Asked Questions</h2>
<AccordionItem id="q1">
How do I reset my password?
</AccordionItem>
<AccordionContent id="q1">
<p>Go to the account settings page and click "Forgot Password."</p>
</AccordionContent>
<AccordionItem id="q2">
What is your return policy?
</AccordionItem>
<AccordionContent id="q2">
<p>We accept returns within 30 days with a receipt.</p>
</AccordionContent>
</Accordion>
);
}
The user of the Accordion doesn’t need to manage the open/close state. The components AccordionItem and AccordionContent talk to each other through the context. This creates a very intuitive and flexible API.
As applications get bigger, performance becomes crucial. A common problem is components re-rendering when nothing has actually changed for them. React provides tools to help with this: React.memo, useMemo, and useCallback.
I use React.memo to prevent a component from re-rendering if its props are the same.
// This component will only re-render if its 'user' prop changes
const UserAvatar = React.memo(function UserAvatar({ user, onSelect }) {
console.log(`Rendering avatar for ${user.name}`); // Let's track renders
return (
<img
src={user.avatarUrl}
alt={user.name}
onClick={() => onSelect(user.id)}
className="avatar"
/>
);
});
// A comparison function can be used for more control
const arePropsEqual = (prevProps, nextProps) => {
// Only re-render if the user's ID or the onSelect function changes
return prevProps.user.id === nextProps.user.id &&
prevProps.onSelect === nextProps.onSelect;
};
const UserAvatarWithComparison = React.memo(UserAvatar, arePropsEqual);
useMemo is for caching the result of an expensive calculation.
function Chart({ dataPoints }) {
// This heavy calculation only runs when `dataPoints` changes
const chartConfig = useMemo(() => {
console.log('Calculating expensive chart config...');
return {
series: dataPoints.map(pt => pt.value),
labels: dataPoints.map(pt => pt.label),
// ... lots of complex formatting logic here
};
}, [dataPoints]); // Dependency array
return <LineChart config={chartConfig} />;
}
useCallback is similar but for functions. It returns a memoized version of a function that only changes if its dependencies change. This is important when passing callbacks to optimized child components.
function ProductList({ products }) {
const [cart, setCart] = useState([]);
// This function identity stays stable unless `setCart` changes (which it won't)
const addToCart = useCallback((productId) => {
setCart(currentCart => [...currentCart, productId]);
}, []); // setCart is stable, so empty deps are often safe here
return (
<div>
<h2>Products</h2>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onAddToCart={addToCart} // Stable reference prevents child re-renders
/>
))}
</div>
);
}
For rendering very long lists, a technique called “windowing” or “virtualization” is essential. Instead of rendering thousands of items at once, you only render what’s visible on the screen.
import { FixedSizeList as List } from 'react-window';
const bigList = Array.from({ length: 10000 }, (_, index) => ({
id: index,
title: `Item ${index + 1}`
}));
function VirtualizedList() {
// Each "Row" component receives an index and a style prop from the List
const Row = ({ index, style }) => {
const item = bigList[index];
return (
<div style={style} className="list-item">
<strong>#{item.id}</strong>: {item.title}
</div>
);
};
return (
<List
height={400} // Visible height of the list
width={300} // Width
itemCount={bigList.length}
itemSize={35} // Height of each row in pixels
>
{Row}
</List>
);
}
This pattern is a lifesaver for performance. The list will feel instant, no matter how many items you have.
Finally, none of this is reliable without tests. Testing React components might seem daunting, but it gets easier. I focus on testing behavior, not implementation details.
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Testing a simple component
test('SubmitButton shows loading state when clicked', () => {
const handleSubmit = jest.fn(); // A mock function
render(<SubmitButton onSubmit={handleSubmit} label="Save" />);
const button = screen.getByRole('button', { name: /save/i });
fireEvent.click(button);
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(button).toHaveTextContent('Saving...'); // Assuming state changes
});
// Testing a form
test('LoginForm submits correct data', async () => {
const user = userEvent.setup();
const mockLogin = jest.fn();
render(<LoginForm onLogin={mockLogin} />);
// Find inputs and button
const emailInput = screen.getByLabelText(/email address/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /log in/i });
// Simulate user typing and clicking
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'secret123');
await user.click(submitButton);
// Assert the mock function was called with the right data
expect(mockLogin).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'secret123'
});
});
// Testing a component with a custom hook
// Jest allows mocking hooks
jest.mock('./useApiData', () => ({
useApiData: () => ({
data: { title: 'Mocked Post', body: 'Mocked content' },
isLoading: false,
hasError: null
})
}));
test('BlogPost displays data from hook', () => {
render(<BlogPost postId={5} />);
expect(screen.getByText('Mocked Post')).toBeInTheDocument();
expect(screen.getByText('Mocked content')).toBeInTheDocument();
});
The goal of testing is to give me confidence. When I change a component deep in the tree, I want to know I haven’t broken a form on a completely different page. These tests act as a safety net.
These seven patterns—component composition, custom hooks, render props, higher-order components, compound components, performance optimization, and testing—form a toolkit. You don’t need to use all of them in every project. Start with composition and custom hooks. Add others when you feel a specific pain point, like repetitive logic or a slow UI.
The real trick is recognizing when your code is becoming hard to change. That’s your signal to step back and consider if one of these patterns could bring back clarity and control. Building scalable applications is less about knowing every advanced trick and more about consistently applying simple, proven structures to keep complexity in check.