Good component design doesn't only require framework knowledge or advanced patterns. Most of it comes down to consistency, clarity, and restraint. These 15 principles are the ones that show up most often in codebases that are pleasant to work in.
1. Function components over class components
Functional components are simpler to read and write. Class components introduce lifecycle methods and this binding that add cognitive overhead without benefit in most cases.
The one exception is Error Boundaries, which still require class-based implementations as of React 18. For everything else, reach for functions.
2. Name every component
Always give your components explicit names — including ones defined inline or passed as props.
// Avoid: unnamed component in an array
const items = [{ label: 'Home', component: () => <div>Home</div> }];
// Better: named component
function HomePanel() {
return <div>Home</div>;
}
const items = [{ label: 'Home', component: HomePanel }];
Unnamed components produce cryptic stack traces. Component or Anonymous in a call stack tells you nothing. Named components tell you exactly where to look.
3. Extract helper functions outside the component
Unless a function genuinely needs to close over component state or props, define it outside the component body.
// Avoid: function re-created on every render
function UserCard({ user }) {
const getInitials = name => name.split(' ').map(n => n[0]).join('');
return <div>{getInitials(user.name)}</div>;
}
// Better: stable reference, easier to test
const getInitials = name => name.split(' ').map(n => n[0]).join('');
function UserCard({ user }) {
return <div>{getInitials(user.name)}</div>;
}
This keeps the component body focused on rendering logic, and makes the helper function straightforward to unit test in isolation.
4. Use configuration objects for repetitive markup
When you find yourself duplicating the same JSX structure multiple times, centralize it in a data structure.
// Avoid: repetitive JSX
function Nav() {
return (
<nav>
<a href="/home">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
);
}
// Better: data-driven
const NAV_LINKS = [
{ href: '/home', label: 'Home' },
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
];
function Nav() {
return (
<nav>
{NAV_LINKS.map(link => (
<a key={link.href} href={link.href}>{link.label}</a>
))}
</nav>
);
}
Adding or changing a nav item now means editing data, not markup. It also makes the rendering logic obviously correct.
5. Keep components small and focused
A component should do one thing. When a component grows to the point where you're scrolling to understand it, it's telling you it wants to be split.
Small, focused components:
- Are easier to reason about in isolation
- Are easier to test
- Re-render less (fewer dependencies means fewer triggers)
- Are easier to reuse
A practical heuristic: if you can't describe what a component does in one sentence without using "and", it's probably doing too much.
6. Destructure props
Destructure at the function signature rather than accessing props repeatedly inside the body.
// Avoid
function Button(props) {
return (
<button className={props.variant} disabled={props.disabled}>
{props.children}
</button>
);
}
// Better
function Button({ variant, disabled, children }) {
return (
<button className={variant} disabled={disabled}>
{children}
</button>
);
}
The signature becomes a declaration of the component's interface. You can see its dependencies at a glance without reading the implementation.
7. Limit the number of props
When a component accepts more than five or so props, it's often a sign that it's doing too much or that multiple concerns haven't been separated.
Fewer props means:
- Fewer reasons for the component to re-render
- Easier to understand the component's purpose
- Easier to use the component correctly
If props are growing, consider whether the component can be split, or whether some props can be grouped into an object.
8. Group related props into objects
Instead of passing several related primitive values separately, group them.
// Avoid: scattered primitives
function UserAvatar({ firstName, lastName, avatarUrl, size }) { ... }
// Better: grouped
function UserAvatar({ user, size }) {
// user.firstName, user.lastName, user.avatarUrl
}
This makes the relationship between values explicit and reduces the component's prop surface area. It also means you can pass the same object to multiple components without restructuring it.
9. Avoid nested ternaries
Ternaries are fine for simple conditional rendering. Nested or chained ternaries are not.
// Avoid: hard to follow
function Status({ status }) {
return status === 'loading'
? <Spinner />
: status === 'error'
? <ErrorMessage />
: <Content />;
}
// Better: readable
function Status({ status }) {
if (status === 'loading') return <Spinner />;
if (status === 'error') return <ErrorMessage />;
return <Content />;
}
Early returns are one of the most underused tools in React. They make conditional rendering declarative and flat.
10. Extract list rendering into separate components
Inline .map() inside JSX clutters the parent component with rendering details that belong elsewhere.
// Avoid: list logic embedded in parent
function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<li key={product.id}>
<img src={product.image} alt={product.name} />
<span>{product.name}</span>
<span>{product.price}</span>
</li>
))}
</ul>
);
}
// Better: delegate to a dedicated component
function ProductItem({ product }) {
return (
<li>
<img src={product.image} alt={product.name} />
<span>{product.name}</span>
<span>{product.price}</span>
</li>
);
}
function ProductList({ products }) {
return (
<ul>
{products.map(product => (
<ProductItem key={product.id} product={product} />
))}
</ul>
);
}
The parent stays focused on structure. ProductItem can be tested, styled, and modified in isolation.
11. Prefer hooks over HOCs and render props
Higher-order components and render props were patterns born of a pre-hooks era. Hooks solve the same problems with a simpler mental model and produce flatter component trees.
// Avoid: HOC pattern
const withAuth = Component => props => {
const user = useAuth();
return <Component {...props} user={user} />;
};
// Better: use the hook directly
function UserProfile() {
const user = useAuth();
return <div>{user.name}</div>;
}
HOCs make it harder to see where props come from. Hooks make the data flow explicit.
12. Extract shared logic into custom hooks
When the same stateful logic appears in multiple components, it belongs in a custom hook.
// Logic shared across components
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
const setAndStore = newValue => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setAndStore];
}
Custom hooks are easy to test, easy to document, and easy to reuse. They're the right abstraction for logic that multiple components need.
13. Move complex render logic out of the return statement
A long, tangled return is a sign that rendering logic should be extracted — either into a variable before the return, or into a dedicated component.
// Avoid: complex logic inside return
function Dashboard({ data }) {
return (
<div>
{data.map(section => (
<section key={section.id}>
{section.items.filter(item => item.visible).map(item => (
<div key={item.id} className={item.type === 'featured' ? 'featured' : 'normal'}>
{item.title}
</div>
))}
</section>
))}
</div>
);
}
// Better: extracted
function SectionItems({ items }) {
return items
.filter(item => item.visible)
.map(item => (
<div key={item.id} data-featured={item.type === 'featured'}>
{item.title}
</div>
));
}
function Dashboard({ data }) {
return (
<div>
{data.map(section => (
<section key={section.id}>
<SectionItems items={section.items} />
</section>
))}
</div>
);
}
14. Use Error Boundaries
Error boundaries catch JavaScript errors in child component trees and render fallback UI instead of crashing the whole application.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
// Wrap sections of the UI that can fail independently
<ErrorBoundary fallback={<p>Failed to load widget.</p>}>
<WeatherWidget />
</ErrorBoundary>
Wrap sections of the UI that can fail independently. A broken sidebar widget shouldn't take down the whole page.
15. Use Suspense for async operations
Suspense lets you declare loading states declaratively rather than managing isLoading booleans manually.
// Avoid: manual loading state
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}
// Better: Suspense + data library (React Query, SWR, etc.)
function UserProfile({ userId }) {
const { data: user } = useSuspenseQuery(['user', userId], () => fetchUser(userId));
return <div>{user.name}</div>;
}
<Suspense fallback={<Spinner />}>
<UserProfile userId={id} />
</Suspense>
The loading state is expressed at the boundary level, not scattered through individual components.
Putting it together
These principles aren't rules to follow blindly. They're defaults that apply to most situations. Small component, few props, named, destructured, logic in hooks — that's the baseline. From there, use your judgment.
The goal is a codebase where any component can be understood in isolation, modified without side effects, and tested without elaborate setup. That's the conscious road to more scalable design.
