Original Address#
https://thetshaped.dev/p/15-react-component-principles-for-better-design
This article only translates the main content, and of course, this is all thanks to Microsoft Translator and ChatGPT (GPT-4o).
Introduction#
This series of articles aims to bridge the gap between React beginners and those who grow into React experts and engineers.
Caution
This is not a beginner's guide, so most of the concepts shared require some knowledge of React.
Tip
Treat everything as an opinion. Software can be built in many ways.
1. Prefer Function Components Over Class Components#
Class components can be verbose and harder to manage. Function components are simpler and easier to understand. Using function components gives you better readability. Compared to state management, lifecycle methods, etc., in class components, there are far fewer things to remember and think about.
The only exception for using class components is for error boundaries.
⛔ Avoid using class components
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
}
increment() {
this.setState(state => ({
count: state.count + 1
}));
}
return (
<div>
Count: {this.state.count}
<button onClick={() => this.increment()}>Increment</button>
</div>
);
}
✅ Prefer using function components
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
2. Name Your Components#
Unnamed components can make debugging difficult and reduce code readability. Named components improve stack traces and make your codebase easier to navigate, manage, and understand. When using named components, it is easier to navigate between errors.
⛔ Avoid using unnamed components
export default () => <div>Details</div>;
✅ Prefer named components
export default function UserDetails() {
return <div>User Details</div>;
}
3. Move Helper Functions Outside of Components#
Nested helper functions inside components can make the component cluttered and harder to read. Placing helper functions outside of components improves readability and separates concerns.
⛔ Avoid nesting helper functions inside components if closures are not needed
function UserProfile({ user }) {
function formatDate(date) {
return date.toLocaleDateString();
}
return <div>Joined: {formatDate(user.joinDate)}</div>;
}
✅ Prefer moving these helper functions outside of the component (place them before the component so you can read the file from top to bottom)
function formatDate(date) {
return date.toLocaleDateString();
}
function UserProfile({ user }) {
return <div>Joined: {formatDate(user.joinDate)}</div>;
}
4. Use Configuration Objects to Extract Repeated Markup#
Hardcoding repeated markup makes the code harder to maintain and update. Using mapping/loops and configuration objects to extract repeated markup makes the code easier to maintain and read. It simplifies updates and additions since changes only need to be made in one place (within the configuration object).
⛔ Avoid hardcoding repeated markup
function ProductList() {
return (
<div>
<div>
<h2>Product 1</h2>
<p>Price: $10</p>
</div>
<div>
<h2>Product 2</h2>
<p>Price: $20</p>
</div>
<div>
<h2>Product 3</h2>
<p>Price: $30</p>
</div>
</div>
);
}
✅ Prefer using configuration objects and loops to extract repeated markup
const products = [
{ id: 1, name: 'Product 1', price: 10 },
{ id: 2, name: 'Product 2', price: 20 },
{ id: 3, name: 'Product 3', price: 30 }
];
function ProductList() {
return (
<div>
{products.map(product => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>Price: ${product.price}</p>
</div>
))}
</div>
);
}
5. Manage Component Size#
Large and verbose components can be difficult to understand, maintain, and test. Smaller, more focused components are easier to read, test, and maintain. Each component should have a single responsibility, which is the reason for changes and re-renders, making the codebase more modular and easier to manage.
⛔ Avoid using large and unwieldy components
function UserProfile({ user }) {
return (
<div>
<div>
<img src={user.avatar} alt={`${user.name}'s avatar`} />
<h2>{user.name}</h2>
</div>
<div>
<h3>Contact</h3>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
</div>
</div>
);
}
✅ Prefer small and readable components
function UserProfile({ user }) {
return (
<div>
<ProfileHeader avatar={user.avatar} name={user.name} />
<ProfileContact email={user.email} phone={user.phone} />
</div>
);
}
6. Destructure Props#
Repetitive props can make components harder to read and maintain. Destructuring props can improve readability and make the code more concise. It reduces repetition and clearly indicates which props are being used.
⛔ Avoid repeating props everywhere in the component
function UserProfile(props) {
return (
<>
<div>Name: {props.name}</div>
<div>Email: {props.email}</div>
</>
);
}
✅ Prefer destructuring your props
function UserProfile({ name, email }) {
return (
<>
<div>Name: {name}</div>
<div>Email: {email}</div>
</>
);
}
7. Manage the Number of Props#
Having too many props can make a component complex and hard to understand. Fewer props make components easier to use and understand.
Most of the time, when we have a component with > 5 props, it indicates that it can be split. But this is not a hard and fast rule, as a "good" example is that an input
field has many props but does not need to be split.
When we have < 5 props, it indicates that something can be extracted. Perhaps we have included too much data in a single component.
Important
Fewer props ⇒ fewer reasons for changes and re-renders.
⛔ Avoid using many props (perhaps > 5, you should split it, but not always, e.g., input
)
function UserProfile({
name, email, avatarUrl, address, paymentProfiles
}) {
return (
<div>
<img src={avatarUrl} alt={`${name}'s avatar`} />
<h1>{name}</h1>
<p>Email: {email}</p>
<p>Address: {address}</p>
<ul>
{paymentProfiles.map(paymentProfile => (
<li key={paymentProfile.id}>
<h2>{paymentProfile.cardNumber}</h2>
<p>{paymentProfile.cardName}</p>
</li>
))}
</ul>
</div>
);
}
✅ Prefer using a small number of props (possibly < 5)
function UserProfile({ user }) {
return (
<Info name={user.name} email={user.email} avatarUrl={user.avatarUrl} />
<Address address={user.address} />
<PaymentProfiles paymentProfiles={user.paymentProfiles} />
);
}
8. Props - Objects vs Primitives#
Passing many primitives can make components cluttered and make managing related data more difficult. Grouping related props into an object can simplify the component interface and improve readability. It makes the code clearer and easier to understand by logically grouping related data.
⛔ Avoid passing primitives when props are related
function Address({ street, city, state, zip }) {
return (
<div>
<p>Street: {street}</p>
<p>City: {city}</p>
<p>State: {state}</p>
<p>ZIP: {zip}</p>
</div>
);
}
✅ Prefer passing an object to group props
function Address({ address }) {
const { street, city, state, zip } = address;
return (
<div>
<p>Street: {street}</p>
<p>City: {city}</p>
<p>State: {state}</p>
<p>ZIP: {zip}</p>
</div>
);
}
9. Manage Ternary Operators#
Nested ternary operators can make code hard to read and maintain. Clear if-else statements can improve code readability and maintainability, making control flow easier to understand and debug.
⛔ Avoid nested or multiple ternary operators - hard to read and follow
function Greeting({ isLoggedIn, age }) {
return (
<div>
{isLoggedIn ? (
age > 18 ? (
"Welcome back!"
) : (
"You are underaged!"
)
) : (
"Please log in."
)}
</div>
);
}
✅ Prefer if-else blocks and explicit return statements in components
function Greeting({ isLoggedIn, age }) {
if (!isLoggedIn) {
return <div>Please log in.</div>;
}
if (age > 18) {
return <div>Welcome back!</div>;
}
return <div>You are underaged!</div>;
}
10. Abstract List Mapping to Separate Components#
Mapping lists directly in the return statement can make components appear cluttered and hard to read. Separating the map operation from the main component into a separate component can make the code cleaner and more readable. The boilerplate code of the main component becomes simpler, and separating the rendering logic from the main structure of the component enhances readability.
Note
The main component does not care about the details.
⛔ Avoid using the map function on lists within components
function PaymentProfilesPage({ paymentProfiles }) {
return (
<h1>Payment Profiles:</h1>
<ul>
{paymentProfiles.map(paymentProfile => (
<li key={paymentProfile.id}>
<h2>{paymentProfile.cardNumber}</h2>
<p>{paymentProfile.cardName}</p>
</li>
))}
</ul>
);
}
✅ Prefer moving the map function outside the component - easier to read. The main component does not care about the details.
function PaymentProfilesPage({ paymentProfiles }) {
return (
<h1>Payment Profiles:</h1>
<PaymentProfilesList paymentProfiles={paymentProfiles} />
);
}
11. Prefer Using Hooks Over HOCs and Render Props#
Higher-order components (HOCs) and render props patterns have traditionally been used to share logic and behavior between components. However, these patterns can lead to complex and deeply nested component trees, making the code harder to read, debug, and maintain. Hooks provide a more concise and declarative way to encapsulate and reuse logic in function components.
Hooks offer a simpler mental model - we access external logic and behavior by composing a set of functions, making the entire JSX template easier to read and understand.
⛔ Avoid using HOCs and render props
function UserProfileForm() {
return (
<Form>
{({ values, handleChange }) => (
<input
value={values.name}
onChange={e => handleChange('name', e.target.value)}
/>
<input
value={values.password}
onChange={e => handleChange('password', e.target.value)}
/>
)}
</Form>
);
}
✅ Prefer using hooks
function UserProfileForm() {
const { values, handleChange } = useForm();
return (
<Form>
<input
value={values.name}
onChange={e => handleChange('name', e.target.value)}
/>
<input
value={values.password}
onChange={e => handleChange('password', e.target.value)}
/>
</Form>
);
}
12. Use Custom Hooks to Reuse and Encapsulate Logic#
Duplicated logic can lead to code redundancy and make maintenance more difficult. Custom hooks allow for code reuse, making components cleaner and easier to maintain. By using custom hooks, you can encapsulate logic, reducing duplication and improving readability. It also makes testing easier.
⛔ Avoid repeating logic in multiple components
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(response => response.json())
.then(data => setUsers(data));
}, []);
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products')
.then(response => response.json())
.then(data => setProducts(data));
}, []);
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
✅ Prefer using custom hooks to encapsulate and reuse logic
function useFetch(url) {
const [data, setData] = useState([]);
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(data => setData(data));
}, [url]);
return data;
}
function UserList() {
const users = useFetch('/api/users');
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
function ProductList() {
const products = useFetch('/api/products');
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
13. Extract Render Functions#
Nesting complex render functions inside components can make the component appear cluttered and hard to read, test, and maintain. Defining render functions outside of the component or using separate components can improve readability and maintainability. This practice keeps the main component clean and focused on its primary functionality.
⛔ Avoid nesting render functions inside components
function UserProfile({ user }) {
function renderProfileDetails() {
return <div>{user.name} - {user.age}</div>;
}
return <div>{renderProfileDetails()}</div>;
}
✅ Prefer extracting render functions outside of the component - above the component or in a separate component.
function renderProfileDetails(user) {
return <div>{user.name} - {user.age}</div>;
}
// OR using a separate component
function ProfileDetails({ user }) {
return <div>{user.name} - {user.age}</div>;
}
function UserProfile({ user }) {
return (
<div>
{renderProfileDetails(user)}
// OR
<ProfileDetails user={user} />;
</div>
);
}
14. Use Error Boundaries#
Uncaught errors can cause the entire application to crash, affecting user experience. Error boundaries allow you to gracefully catch and handle errors, improving the resilience of the application. This ensures a better user experience by displaying a fallback UI instead of crashing the entire application.
⛔ Avoid letting errors in child components crash the entire application
function App() {
return <UserProfile />;
}
✅ Prefer using error boundaries to catch and handle errors in the child component tree
function App() {
return (
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
);
}
15. Use Suspense#
Manually managing loading states can be repetitive and error-prone. Suspense simplifies handling loading states by providing a declarative way to manage asynchronous operations. This reduces boilerplate code and makes component logic cleaner.
⛔ Avoid manually handling loading states for asynchronous operations
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user')
.then(response => response.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
✅ Prefer using Suspense to elegantly handle asynchronous operations and loading states
import { UserProfile } from './UserProfile';
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}