React Best Practices
Writing React code that works is the first step. Writing React code that remains maintainable as a team and a codebase grow is a different skill. These 15 practices are distilled from years of community experience and official React guidance. None of them is optional for professional-grade code.
1. One Component Per File
Each component should live in its own file. This makes components easier to find, import, test in isolation, and review in pull requests. The file name should match the component name exactly.
// ✗ Avoid — multiple components buried in one file // UserCard.tsx contains UserCard, Avatar, and UserBadge // ✓ Preferred // components/UserCard/UserCard.tsx // components/UserCard/Avatar.tsx // components/UserCard/UserBadge.tsx // components/UserCard/index.ts ← barrel export
2. PascalCase Component Names
React uses the case of the first letter to distinguish components from HTML elements. <button> is a native element. <Button> is your component. Always name components with PascalCase — every word capitalized, no hyphens or underscores.
3. Extract Logic into Custom Hooks
If a component's useEffect / state logic is more than a few lines, extract it into a custom hook. Components should express structure; hooks should express behavior.
// ✗ Logic buried in component
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(u => { setUser(u); setLoading(false) })
}, [userId])
// ... render
}
// ✓ Logic extracted — hook is reusable and independently testable
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUser(userId).then(u => { setUser(u); setLoading(false) })
}, [userId])
return { user, loading }
}
function UserProfile({ userId }: { userId: string }) {
const { user, loading } = useUser(userId)
if (loading) return <Spinner />
return <div>{user?.name}</div>
}4. Prefer Composition Over Prop Drilling
Instead of passing data through multiple intermediate components (prop drilling), use the children prop or slot props to compose components. The parent passes pre-built subtrees, not raw data.
// ✗ Drilling — Layout needs to know about Header's internal props
<Layout title="Dashboard" userAvatar={url} onLogout={logout} />
// ✓ Composition — Layout receives built components as children
<Layout
header={<Header title="Dashboard"><Avatar src={url} onLogout={logout} /></Header>}
>
<DashboardContent />
</Layout>5. Colocate State Close to Where It Is Used
State should live as low in the tree as possible while still serving all components that need it. Lifting state too high causes unnecessary re-renders and makes code harder to reason about. Only move state up when genuinely needed by multiple unrelated components.
6. Handle All Three Async States
Every data fetch has three outcomes. Handle all three explicitly — omitting error or loading states leads to broken UX and hard-to-reproduce bugs:
function UserList() {
const { data, loading, error } = useUsers()
if (loading) return <Skeleton />
if (error) return <ErrorMessage message={error} />
// Only reach here when data is available
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}7. Use TypeScript
TypeScript eliminates entire bug categories — wrong prop types, undefined property access, missing required fields — at compile time rather than runtime. Every professional React codebase uses it. The upfront investment pays back immediately in reduced debugging time.
8. Never Use Array Index as Key
React uses keys to match old rendered elements to new ones. If items reorder, get inserted, or get removed, index-based keys cause mismatched state, broken animations, and subtle rendering bugs. Always use a stable, unique identifier:
// ✗ Index as key — breaks when items are reordered or deleted
{items.map((item, i) => <Item key={i} {...item} />)}
// ✓ Stable ID as key
{items.map(item => <Item key={item.id} {...item} />)}9. Lazy-Load Routes and Heavy Components
Ship only what the user needs for the current page. Lazy loading splits the JavaScript bundle so heavy components or rarely-visited pages do not delay the initial load:
import { lazy, Suspense } from 'react'
// Component is fetched only when first rendered
const HeavyChart = lazy(() => import('./HeavyChart'))
const SettingsPage = lazy(() => import('../pages/SettingsPage'))
function App() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart data={chartData} />
</Suspense>
)
}10. Memoize Only When You Have Measured a Problem
React.memo, useMemo, and useCallback add complexity and have their own overhead. Apply them only after profiling with React DevTools reveals an actual performance problem. Premature memoization makes code harder to read for zero gain.
11. Use the ESLint react-hooks Plugin
The eslint-plugin-react-hooks package enforces the Rules of Hooks and warns about missing useEffect dependencies — two of the most common React bug sources. It should be enabled in every project:
// .eslintrc or eslint.config.js
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}12. Avoid Inline Object and Array Literals in JSX Props
Inline objects and arrays are new references on every render, causing child components to re-render even with React.memo. Move them outside the component or memoize them:
// ✗ New object reference every render — defeats React.memo on Chart
<Chart style={{ color: 'red' }} options={{ grid: true }} />
// ✓ Stable references
const CHART_STYLE = { color: 'red' }
const CHART_OPTIONS = { grid: true }
<Chart style={CHART_STYLE} options={CHART_OPTIONS} />13. Test Behavior, Not Implementation
Tests should verify what the user sees and does, not internal implementation details like state values or function calls. Use React Testing Library, which queries the DOM the same way a user would:
// ✗ Implementation testing — brittle, breaks on refactor
expect(wrapper.state('count')).toBe(1)
// ✓ Behavior testing — tests what the user sees
const { getByText } = render(<Counter />)
fireEvent.click(getByText('+'))
expect(getByText('Count: 1')).toBeInTheDocument()14. Use Error Boundaries Around Async Boundaries
Wrap each major section of the UI with an Error Boundary so a crash in one feature does not white-screen the entire application. Pair them with Suspense around data-fetching components:
<ErrorBoundary fallback={<ErrorCard message="Feed failed to load" />}>
<Suspense fallback={<FeedSkeleton />}>
<NewsFeed />
</Suspense>
</ErrorBoundary>15. Keep Render Functions Pure
A React component's render output must depend only on its props and state. Never call setX(), trigger API calls, modify external variables, or read from Math.random() / Date.now() directly in the component body. Side effects belong in event handlers and useEffect.
// ✗ Side effect in render — fires on every render, causes infinite loops
function UserList() {
fetchUsers() // ← called during render!
return <div />
}
// ✓ Side effect in useEffect — runs once after mount
function UserList() {
const [users, setUsers] = useState<User[]>([])
useEffect(() => { fetchUsers().then(setUsers) }, [])
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}