15 React Component Principles & Best Practices for Better Software Architecture & Design
The conscious road to more scalable designs of React applications.
Intro
I’ve been using React since 2018, and since then, I’ve been talking with many colleagues about a set of good practices and principles for building React Applications. I’m planning to curate a series of articles that consist of a set of React principles and best practices for better software architecture and design. In the first article of the series, I’m focusing on React Components.
I’m bringing my curiosity and experience in Software Design & Architecture into building well-designed, maintainable, and scalable React applications.
The series of articles aims to bridge the gap between React beginners and those growing as React experts and engineers.
Caution: This is not a beginner’s guide, so most of the shared concepts require some React knowledge.
If you find yourself in that situation, refresh your fundamentals and continue.
As with every piece of advice and information on the Internet, don’t go to extremes without questioning it.
💡 Take everything as an opinion. Software can be built in multiple ways.
In this article, we will go through:
Function Components vs. Class Components
Name of Components
Helper Functions
Repetitive Markup
Component’s Size and Length
Props
Ternary Operators
Lists Mapping
Hooks vs. HOCs and Render Props
Custom Hooks
Render Functions
Error Boundaries
Suspense
1. Favor Function Components over Class Components
Class components can be verbose and harder to manage. Function components are simpler and easier to understand. With function components, you have better readability. The things you have to remember and think about are a lot smaller compared to class components’s state management, lifecycle methods, etc.
The only exception for using Class Components is when you want to use 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
Nameless components can make debugging difficult and reduce code readability. Named components improve the stack trace and make your codebase easier to navigate, manage, and understand. You can navigate between errors much easier when you use named components.
⛔ Avoid using nameless components
export default () => <div>Details</div>;
✅ Prefer naming your components
export default function UserDetails() {
return <div>User Details</div>;
}
3. Move Helper Functions Outside of Components
Nesting helper functions inside components can clutter the component and make it harder to read. Keeping helper functions outside the components improves readability and separates concerns.
⛔ Avoid nesting your helper functions inside your components if closure is not needed
function UserProfile({ user }) {
function formatDate(date) {
return date.toLocaleDateString();
}
return <div>Joined: {formatDate(user.joinDate)}</div>;
}
✅ Prefer moving these helper functions outside the component (before it, 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. Extract Repetitive Markup with Config Objects
Hardcoding repetitive markup makes the code harder to maintain and update. Extracting repetitive markup with maps/loops and configuration objects makes the code more maintainable and readable. It simplifies updates and additions, as changes only need to be made in one place (inside the configuration object).
⛔ Avoid hardcoding repetitive 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 extracting repetitive markup with configuration objects and loops
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 the Component’s Size
Large and lengthy components can be difficult to understand, maintain, and test. Smaller and more focused components are easier to read, test, and maintain. Each component has a single responsibility, reason to change and rerender, making the codebase more modular and easier to manage.
⛔ Avoid big and nasty components
function UserProfile({ user }) {
return (
<div>
<div>
<img src={user.avatar} alt={`${user.name}'s avatar`} />
<h2>{user.name}</h2>
</d
<div>
<h3>Contact</h3>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
</div>
</div>
);
}
✅ Prefer small and easy-to-read components
function UserProfile({ user }) {
return (
<div>
<ProfileHeader avatar={user.avatar} name={user.name} /
<ProfileContact email={user.email} phone={user.phone} />
</div>
);
}
6. Destructure Props
Repeating props can make the component harder to read and maintain. Destructuring the props improves readability and makes the code more consious. It reduces repetition and makes it clear which props are being used.
⛔ Avoid repeating props everywhere in your 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 harder to understand. Fewer props makes a component easier to use and understand.
Most of the time, when we have a component with > 5 props, it’s a sign that it can be splitted. But that’s not a hard rule to follow because as an “okay” example the input
field has many props but it’s not needed to be splitted.
When we have < 5 props, it’s a sign that something can be extracted. Maybe we have too much data into a single component.
Less props ⇒ less reasons to change and rerender.
⛔ Avoid using many props (maybe > 5, you should split it, but not always, ex: 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 small number of props (maybe < 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 clutter the component and make it harder to manage related data. Grouping related props into an object simplifies the component interface and improves readability. It makes the code cleaner and easier to understand by logically grouping related data.
⛔ Avoid passing primitives when props are somehow 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, grouping the 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 the code difficult to read and maintain. Clear if-else statements enhance hode readability and maintainability. They make the 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 your component
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 Lists Mapping into Separate Components
Directly mapping over lists in the return statement can make the component cluttered and harder to read. Separating the map operation from the main component into individual component from makes the code cleaner and easier to read. The main component’s boilerplate becomes simpler. It separates the rendering logic from the component’s main structure, enhancing readability.
The main component doesn’t care about the details.
⛔ Avoid using map function over a list inside your component
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 - easy to read. The main component doesn’t care about the details.
function PaymentProfilesPage({ paymentProfiles }) {
return (
<h1>Payment Profiles:</h1>
<PaymentProfilesList paymentProfiles={paymentProfiles} />
);
}
11. Favor Hooks over HOCs and Render Props
HOCs (Higher-Order Components) and render props patterns have been traditionally used for sharing logic and behavior across components. However, these patterns can lead to complex and deeply nested component trees, making the code harder to read, debug, and maintain. Hooks offer a more straightforward and declarative approach to encapsulating and reusing logic within functional components.
Hooks offer a more simpler mental model - we compose a set of functions to access external logic and behavior, making the overall JSX template simpler 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. Reuse and Encapsulate Logic with Custom Hooks
Duplicating logic leads to code redundancy and makes maintaince harder. Custom hooks allow for code reuse, making components cleaner and more maintainable. With custom hooks, the logic is encapsulated, reducing duplication and improving readability. It also makes its testing much easier.
⛔ Avoid duplicating logic across 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 encapsulating and reusing logic with custom hooks
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 clutter the component and make it harder to read, test, and maintain. Defining render functions outside the component or using separate components improvemes readability and maintainability. It keeps the main component clean and focuses on its primary purpose.
⛔ Avoid nesting your render functions inside your components
function UserProfile({ user }) {
function renderProfileDetails() {
return <div>{user.name} - {user.age}</div>;
}
return <div>{renderProfileDetails()}</div>;
}
✅ Prefer extracting your render functions outside your components - on top of them or separate components.
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
Unhandled errors can crash the whole application, affecting the user experience. Error Boundaries allow you to catch and handle errors gracefully, improving the application’s resilience. This ensures a better user experience by displaying fallback UIs, instead of crashing the entire app.
⛔ Avoid allowing errors in child components to crash the entire application
function App() {
return <UserProfile />;
}
✅ Prefer using error boundaries to catch and handle errors in child component trees
function App() {
return (
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
);
}
15. Use Suspense
Manually managing loading states can be repetitive and error-prone. Suspense simplifies the handling of async operations by providing a declarative way to manage loading states. This reduces boilerplate code and makes the component logic cleaner.
⛔ Avoid manually handling loading states for async 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 handle async operations and loading states gracefully
import { UserProfile } from './UserProfile';
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}
TL;DR
Favor Function Components over Class Components
Name your Components
Move Helper Functions Outside of Components
Extract Repetitive Markup with Config Objects
Manage the Component’s Size
Destructure Props
Manage the Number of Props
Props - Objects vs. Primitives
Manage Ternary Operators
Abstract Lists Mapping into Separate Components
Favor Hooks over HOCs and Render Props
Reuse and Encapsulate Logic with Custom Hooks
Extract Render Functions
Use Error Boundaries
Use Suspense
Conclusion
In upcoming articles, we will dive deeper into other crucial areas of React development, including state management, testing, application’s organization, and much more. Stay tuned to continue improving your skills and building better React apps.
This is the best example compendium I've seen for react in a while! I salute your effort for trying to make code look great on substack!
Love the object tip! It makes managing lists so much easier! I dont use Suspense often, I should consider this as often as I can. Thank you for these great tips! :D