React Best Practices for Building Scalable Applications
React gives you enough freedom to build almost anything, and enough rope to hang yourself. Knowing what the React team and the broader community recommend saves you from painful refactors later. This guide covers the patterns that production React apps follow.
1. Keep Components Small and Focused
Each component should do one thing well. If a component is doing too much, split it.
// Too much responsibility in one component
function UserDashboard() {
// fetches user, renders profile, handles theme, manages notifications...
}
// Better — compose small, focused components
function UserDashboard() {
return (
<main>
<UserProfile />
<NotificationPanel />
<ActivityFeed />
</main>
);
}
A component that exceeds ~150 lines is usually a sign it needs to be split. Single responsibility isn't just a backend principle.
2. Use Custom Hooks to Extract Logic
Logic does not belong in the component body. Move stateful logic into custom hooks. They are reusable, testable, and keep your JSX clean.
// Before — logic mixed into component
function ArticleList() {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/articles")
.then(r => r.json())
.then(data => { setArticles(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
// ... render
}
// After — clean separation
function useArticles() {
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch("/api/articles")
.then(r => r.json())
.then(data => { setArticles(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
return { articles, loading, error };
}
function ArticleList() {
const { articles, loading, error } = useArticles();
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <ul>{articles.map(a => <ArticleItem key={a.id} article={a} />)}</ul>;
}
3. Prefer Composition Over Prop Drilling
When you find yourself passing props 3+ levels deep, reach for composition or Context instead of more props.
// Prop drilling — painful at scale
<Layout user={user} theme={theme} onLogout={onLogout}>
<Sidebar user={user} theme={theme} />
<Content user={user} onLogout={onLogout} />
</Layout>
// Composition — pass elements, not data
function Layout({ sidebar, content }: { sidebar: React.ReactNode; content: React.ReactNode }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{content}</main>
</div>
);
}
// At usage site — owner has the data, children don't need to care
<Layout
sidebar={<Sidebar user={user} theme={theme} />}
content={<Content user={user} onLogout={onLogout} />}
/>
Use React Context for truly global state (theme, auth, locale), not for every piece of shared data.
4. Derive State Instead of Syncing It
One of the most common React bugs comes from duplicating state. Derive values from existing state instead of creating a second useState to track something you can compute.
// Bad — two states that must be kept in sync
const [items, setItems] = useState(initialItems);
const [selectedCount, setSelectedCount] = useState(0); // can drift!
// Good — derive it
const [items, setItems] = useState(initialItems);
const selectedCount = items.filter(i => i.selected).length; // always correct
For expensive derivations, wrap in useMemo:
const sortedArticles = useMemo(
() => [...articles].sort((a, b) => b.date.localeCompare(a.date)),
[articles]
);
5. Understand When to Use useEffect
useEffect is for synchronising React state with something outside React (a DOM API, a subscription, a timer). It is not a lifecycle hook replacement.
// Wrong use — this is just event handling, not synchronisation
useEffect(() => {
if (submitted) {
sendForm(data);
}
}, [submitted]);
// Correct — handle it in the event handler
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
sendForm(data);
}
// Correct use of useEffect — syncing with an external subscription
useEffect(() => {
const unsubscribe = store.subscribe(callback);
return () => unsubscribe(); // always clean up
}, []);
Always return a cleanup function when your effect creates subscriptions, timers, or observers.
6. Use TypeScript for All New React Code
TypeScript makes refactoring safe and component APIs explicit. Always type your props.
interface ArticleCardProps {
title: string;
abstract: string;
readingTime: string;
tags: string[];
onRead: (slug: string) => void;
}
function ArticleCard({ title, abstract, readingTime, tags, onRead }: ArticleCardProps) {
return (
<article>
<h3>{title}</h3>
<p>{abstract}</p>
<span>{readingTime}</span>
<ul>{tags.map(tag => <li key={tag}>{tag}</li>)}</ul>
</article>
);
}
Avoid React.FC. It adds an implicit children prop and obscures return types. Just type the props directly.
7. Optimise Renders Thoughtfully
Do not wrap everything in memo, useMemo, and useCallback by default. The overhead exists. Optimise when you have a measured problem.
// Only memoize when:
// 1. The component re-renders frequently with the same props
// 2. The render is expensive
const ExpensiveChart = React.memo(function ExpensiveChart({ data }: { data: DataPoint[] }) {
// heavy D3 computation
return <svg>...</svg>;
});
// Stable callback reference for a child that is memo-wrapped
const handleItemClick = useCallback((id: string) => {
setSelectedId(id);
}, []); // stable — no deps that change
Profile first with React DevTools Profiler. Premature optimisation in React is as real a problem as anywhere else.
8. Keep State as Close to Where It's Used as Possible
Do not lift state higher than necessary. State that only one component needs should live in that component.
// Lifting too high — Search state does not need to be in App
function App() {
const [searchQuery, setSearchQuery] = useState(""); // only used in SearchBar
return <SearchBar query={searchQuery} onChange={setSearchQuery} />;
}
// Better — SearchBar owns its own state
function SearchBar() {
const [query, setQuery] = useState("");
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Only lift state when two or more sibling components need to share it.
9. Handle Loading and Error States Explicitly
Every data-fetching component should handle three states: loading, error, and success. Skipping error states is a silent UX failure.
function ArticlePage({ slug }: { slug: string }) {
const { article, loading, error } = useArticle(slug);
if (loading) return <ArticleSkeleton />;
if (error) return <ErrorBanner message={error.message} />;
if (!article) return <NotFound />;
return <ArticleBody article={article} />;
}
Use React's built-in Error Boundaries to catch rendering errors at the component tree level:
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary fallback={<div>Something went wrong.</div>}>
<ArticlePage slug={slug} />
</ErrorBoundary>
10. Structure Your Project for Scale
A flat structure works for small apps. For anything larger, organise by feature, not by type:
src/
├── features/
│ ├── articles/
│ │ ├── ArticleCard.tsx
│ │ ├── ArticleList.tsx
│ │ ├── useArticles.ts
│ │ └── articles.types.ts
│ └── auth/
│ ├── LoginForm.tsx
│ ├── useAuth.ts
│ └── auth.types.ts
├── components/ ← shared, generic UI
│ ├── Button.tsx
│ ├── Spinner.tsx
│ └── ErrorBanner.tsx
├── hooks/ ← shared custom hooks
├── lib/ ← third-party wrappers
└── App.tsx
This keeps related code co-located and makes features easy to find, modify, or delete.
Conclusion
React best practices are not arbitrary rules. Each one addresses a real class of bugs or maintenance pain that teams run into at scale. Keep components focused, extract logic into hooks, derive rather than sync state, and type everything. These habits compound: a codebase that follows them stays readable and changeable as it grows.
References: