Table of Contents
- Component Composition Over Inheritance
- Embrace Functional Components & Hooks
- Optimize State Management
- Leverage Memoization to Prevent Unnecessary Re-renders
- Use Keys Wisely in Lists
- Master
useEffectfor Side Effects - Implement Code Splitting for Faster Load Times
- Prioritize Accessibility (a11y)
- Write Comprehensive Tests
- Optimize Bundle Size
1. Component Composition Over Inheritance
React’s core philosophy encourages composition over inheritance for reusing component logic. Unlike class-based inheritance (which can lead to rigid, tightly coupled code), composition lets you build complex components by combining smaller, reusable ones.
Why It Matters:
- Flexibility: Compose components dynamically to adapt to different use cases.
- Reusability: Small, focused components are easier to reuse across your app.
- Maintainability: Avoids “prop drilling” (passing props through multiple layers) and keeps logic localized.
Example:
Instead of creating a PrimaryButton and SecondaryButton class that inherit from a base Button class, compose a reusable Button component with props to control its variant:
// Reusable Button component
const Button = ({ variant = "primary", children, ...props }) => {
const baseStyles = "px-4 py-2 rounded font-medium";
const variantStyles = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
};
return (
<button className={`${baseStyles} ${variantStyles[variant]}`} {...props}>
{children}
</button>
);
};
// Usage
const App = () => (
<div>
<Button variant="primary" onClick={() => console.log("Primary clicked")}>
Submit
</Button>
<Button variant="secondary" onClick={() => console.log("Secondary clicked")}>
Cancel
</Button>
</div>
);
2. Embrace Functional Components & Hooks
Gone are the days of class components being the norm. React 16.8 introduced Hooks, which let you use state and lifecycle features in functional components. Today, functional components with Hooks are the standard for writing React code.
Why It Matters:
- Simpler Code: Functional components are more concise than class components (no
this,constructor, or lifecycle methods likecomponentDidMount). - Reusable Logic: Hooks like
useState,useEffect, and custom hooks let you extract and reuse stateful logic. - Better Performance: Functional components have a smaller footprint and are optimized by React’s engine.
Example:
Compare a class component with a functional component using Hooks:
Class Component (Old Way):
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
}
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.increment}>{this.state.count}</button>;
}
}
Functional Component with Hooks (Modern Way):
import { useState } from "react";
const Counter = () => {
const [count, setCount] = useState(0); // State
const increment = () => setCount(prev => prev + 1); // Handler
return <button onClick={increment}>{count}</button>;
};
Key Hooks to Learn: useState, useEffect, useContext, useReducer, useCallback, useMemo, and custom hooks.
3. Optimize State Management
State management is critical in React apps, but overcomplicating it can lead to bugs and poor performance. Choose the right tool for the job based on your app’s size and complexity.
When to Use Which Tool:
- Local State: Use
useStatefor simple state (e.g., form inputs, toggles). - Complex Local State: Use
useReducerfor state with multiple sub-values or complex transitions (e.g., shopping carts, multi-step forms). - Global State (Small Apps): Use React’s
Context APIfor sharing state across components without prop drilling. - Global State (Large Apps): Use libraries like Redux, Zustand, or Jotai for predictable, scalable state management.
Example: Using useReducer for Complex State
For a form with multiple fields (name, email, password), useReducer simplifies state updates:
import { useReducer } from "react";
const formReducer = (state, action) => {
switch (action.type) {
case "UPDATE_FIELD":
return { ...state, [action.field]: action.value };
case "RESET":
return { name: "", email: "", password: "" };
default:
return state;
}
};
const RegisterForm = () => {
const [formState, dispatch] = useReducer(formReducer, {
name: "",
email: "",
password: "",
});
const handleChange = (e) => {
dispatch({
type: "UPDATE_FIELD",
field: e.target.name,
value: e.target.value,
});
};
return (
<form>
<input
name="name"
value={formState.name}
onChange={handleChange}
placeholder="Name"
/>
{/* Email and password inputs follow the same pattern */}
</form>
);
};
4. Leverage Memoization to Prevent Unnecessary Re-renders
By default, React re-renders a component whenever its props or state change. However, this can lead to unnecessary re-renders (e.g., a child component re-rendering because a parent’s state changed, even if the child’s props didn’t). Memoization helps optimize this.
Key Memoization Tools:
React.memo: Memoizes a functional component, preventing re-renders if props haven’t changed.useMemo: Memoizes the result of an expensive calculation, re-computing only when dependencies change.useCallback: Memoizes a function, preventing it from being recreated on every render (useful for functions passed as props).
Example:
import { useState, useCallback, memo } from "react";
// Memoized Child Component (only re-renders if `onClick` or `label` change)
const MemoizedButton = memo(({ label, onClick }) => {
console.log("MemoizedButton re-rendered");
return <button onClick={onClick}>{label}</button>;
});
const Parent = () => {
const [count, setCount] = useState(0);
// Memoize the onClick function to prevent re-creation
const handleClick = useCallback(() => {
console.log("Button clicked");
}, []); // Empty dependency array: function never changes
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(prev => prev + 1)}>Increment Count</button>
<MemoizedButton label="Click Me" onClick={handleClick} />
</div>
);
};
Here, MemoizedButton won’t re-render when count changes because handleClick is memoized with useCallback, and React.memo skips re-renders for unchanged props.
5. Use Keys Wisely in Lists
When rendering lists in React (e.g., with map), you must provide a key prop to each list item. Keys help React identify which items have changed, been added, or removed, enabling efficient re-renders.
Why It Matters:
- Performance: Without keys, React may re-render the entire list instead of just changed items.
- Avoid Bugs: Incorrect keys can cause UI inconsistencies (e.g., form inputs losing focus).
Best Practices:
- Use Unique, Stable Keys: Keys must be unique among siblings and stable across re-renders (avoid using
indexas a key unless the list is static and never reordered). - Prefer IDs: Use database IDs, UUIDs, or unique values from your data (e.g.,
item.id).
Bad Example (Using Index):
// ❌ Avoid: index can cause issues if list is reordered/filtered
const TodoList = ({ todos }) => (
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo.text}</li> // Risky!
))}
</ul>
);
Good Example (Using Unique ID):
// ✅ Better: use a unique ID from the data
const TodoList = ({ todos }) => (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li> // Safe and efficient
))}
</ul>
);
6. Master useEffect for Side Effects
useEffect is React’s primary tool for handling side effects (e.g., data fetching, subscriptions, DOM manipulations). It replaces lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.
Key Concepts:
- Dependency Array: The second argument to
useEffectdetermines when the effect runs. An empty array ([]) runs the effect once on mount; omitted, it runs on every render. - Cleanup Function: Return a function from
useEffectto clean up side effects (e.g., cancel subscriptions, clear timers) and prevent memory leaks.
Example: Fetching Data with Cleanup
import { useState, useEffect } from "react";
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true; // Flag to check if component is still mounted
const fetchUser = async () => {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
if (isMounted) { // Only update state if component is mounted
setUser(data);
setLoading(false);
}
} catch (error) {
if (isMounted) setLoading(false);
}
};
fetchUser();
// Cleanup: Set isMounted to false when component unmounts
return () => { isMounted = false; };
}, [userId]); // Re-run effect when `userId` changes
if (loading) return <p>Loading...</p>;
if (!user) return <p>User not found</p>;
return <div>{user.name}</div>;
};
Why Cleanup Matters: Prevents state updates on unmounted components (which cause React warnings and memory leaks).
7. Implement Code Splitting
Code splitting is a technique that breaks your app’s bundle into smaller chunks, loaded on demand. This reduces initial load time, improving performance—especially for large apps.
How to Do It:
React.lazy: Dynamically imports a component, loading it only when needed.Suspense: Displays a fallback UI (e.g., “Loading…”) while the lazy component loads.
Example:
import { lazy, Suspense } from "react";
// Dynamically import the HeavyComponent (loaded only when rendered)
const HeavyComponent = lazy(() => import("./HeavyComponent"));
const App = () => (
<div>
<h1>My App</h1>
{/* Show fallback while HeavyComponent loads */}
<Suspense fallback={<p>Loading...</p>}>
<HeavyComponent />
</Suspense>
</div>
);
Advanced: Use React Router to split code by route (e.g., load /about and /contact routes only when the user navigates to them).
8. Prioritize Accessibility (a11y)
Accessibility ensures your app is usable by everyone, including people with disabilities. React makes it easy to build accessible UIs with semantic HTML and ARIA attributes.
Key Practices:
- Semantic HTML: Use
<button>,<input>,<nav>, and<main>instead of generic<div>s. - ARIA Attributes: Use
aria-label,aria-hidden, andaria-liveto enhance accessibility for screen readers. - Keyboard Navigation: Ensure all interactive elements are focusable and usable via keyboard (e.g.,
Tabto navigate,Enter/Spaceto activate). - Focus Management: Use
refto control focus (e.g., after form submission, focus on the success message).
Example: Accessible Button
// Bad: Non-semantic div with onClick (not keyboard-accessible)
<div onClick={() => console.log("Clicked")} style={{ cursor: "pointer" }}>
Click Me
</div>
// Good: Semantic button with keyboard support and ARIA label
<button
onClick={() => console.log("Clicked")}
aria-label="Submit form"
onKeyPress={(e) => e.key === "Enter" && console.log("Clicked")}
>
Submit
</button>
Tools to Audit Accessibility: Use Lighthouse (Chrome DevTools) or axe DevTools.
9. Write Comprehensive Tests
Testing ensures your React components work as expected and prevents regressions. The React ecosystem has robust tools for testing:
- Jest: A test runner and assertion library.
- React Testing Library: A lightweight library for rendering components and interacting with them like a user.
Example: Testing a Button Component
import { render, screen, fireEvent } from "@testing-library/react";
import Button from "./Button";
test("renders button with label and triggers onClick", () => {
const handleClick = jest.fn(); // Mock function
render(<Button label="Submit" onClick={handleClick} />);
// Check if button is rendered
const button = screen.getByText("Submit");
expect(button).toBeInTheDocument();
// Simulate click and check if handler is called
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
What to Test:
- Component rendering.
- User interactions (clicks, form inputs).
- State updates and side effects.
10. Optimize Bundle Size
A large bundle size slows down your app’s load time. Optimize it by reducing unnecessary code and dependencies.
How to Optimize:
- Tree Shaking: Use ES6
import/exportsyntax (notrequire) to let bundlers like Webpack eliminate unused code. - Avoid Large Dependencies: Replace heavy libraries with smaller alternatives (e.g.,
date-fnsinstead ofmoment.js). - Analyze Bundles: Use tools like
webpack-bundle-analyzerto identify large chunks.
Example: Tree Shaking
// Bad: Imports entire lodash library (large!)
import _ from "lodash";
_.debounce(...);
// Good: Imports only debounce (smaller bundle)
import { debounce } from "lodash-es"; // Use lodash-es for ES modules
debounce(...);
Tool: Run npx webpack-bundle-analyzer to visualize your bundle and find optimization opportunities.
Conclusion
These 10 tips will help you write cleaner, more performant, and maintainable React code. From component composition to accessibility and bundle optimization, each practice addresses a common pain point in React development. Remember, React is constantly evolving—stay updated with the official docs and community best practices to keep refining your skills.