React Hooks have revolutionized how we write React components, but they come with their own set of best practices that every developer should follow. Let's dive into the essential patterns that will make your Hooks code cleaner and more maintainable.
Understanding the Rules of Hooks
The two fundamental rules of Hooks are: only call Hooks at the top level, and only call Hooks from React functions. These rules exist because Hooks rely on call order to work correctly. Breaking these rules leads to bugs that are difficult to diagnose.
Always Call Hooks at the Top Level
Never call Hooks inside loops, conditions, or nested functions. Hooks should always be called at the top level of your React function, before any early returns. This ensures Hooks are called in the same order on every render, which is essential for React to properly preserve state between calls.
// ❌ Bad: Conditional Hook
if (condition) {
useEffect(() => {
// ...
}, []);
}
// ✅ Good: Always called
useEffect(() => {
if (condition) {
// ...
}
}, []);useState Best Practices
When using useState, keep your state simple and granular. Don't try to store everything in a single state object unless the values are truly related. Multiple state variables are often easier to manage and update.
Functional Updates
When your new state depends on the previous state, use the functional update form. This prevents stale closure issues and ensures you're always working with the latest state.
const [count, setCount] = useState(0);
// ✅ Good: Functional update
setCount(prev => prev + 1);
// ❌ Bad: May use stale value
setCount(count + 1);Lazy Initialization
For expensive initial state calculations, use lazy initialization with a function. This function runs only once during initial render, not on every re-render.
const [data, setData] = useState(() => {
return expensiveCalculation();
});useEffect Patterns
The useEffect Hook is where side effects live, but it's also where many bugs originate. Understanding dependencies is crucial for correct usage.
The Dependency Array
Always include all values from the component scope that are used inside the effect. React's ESLint plugin will warn you about missing dependencies, and you should heed these warnings.
useEffect(() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
}, [props.source]); // ✅ Include all dependenciesCleanup Functions
Always return a cleanup function from useEffect when you have subscriptions, timers, or any side effect that needs cleanup. This prevents memory leaks and ensures proper resource management.
useEffect(() => {
const timer = setInterval(() => {
console.log('Timer tick');
}, 1000);
return () => clearInterval(timer); // Cleanup
}, []);Separate Concerns
Don't try to do too much in a single useEffect. Split effects by concern rather than lifecycle method. This makes your code more readable and easier to maintain.
Custom Hooks
Custom Hooks are the key to reusing stateful logic. Extract common logic into custom Hooks to reduce duplication and improve code organization.
Naming Conventions
Always prefix custom Hooks with "use" to clearly indicate they follow the Rules of Hooks. This also enables ESLint to apply the exhaustive-deps rule to your custom Hooks.
// ✅ Good: Follows naming convention
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
// ...
return size;
}Single Responsibility
Each custom Hook should have a single, well-defined responsibility. If your Hook is doing multiple unrelated things, consider splitting it into multiple Hooks.
useCallback and useMemo
These Hooks are optimization tools, not something you should use everywhere. Use them only when you've identified a performance problem through profiling.
When to Use useCallback
Use useCallback when passing callbacks to optimized child components or when a callback is a dependency in other Hooks. Don't wrap every function in useCallback by default.
const handleClick = useCallback(() => {
doSomething(dependency);
}, [dependency]);When to Use useMemo
Use useMemo for expensive calculations that you don't want to repeat on every render. Be careful not to optimize prematurely—measure first, then optimize.
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);useContext Patterns
Context is great for sharing global state, but it can cause unnecessary re-renders if not used carefully. Split your context when you have values that change at different rates.
Separate Contexts
Instead of one large context object, create separate contexts for different concerns. This prevents components from re-rendering when unrelated values change.
// ✅ Good: Separate contexts
const UserContext = createContext<UserContextValue>(null);
const ThemeContext = createContext<ThemeContextValue>(null);Memoize Context Values
Memoize context values to prevent unnecessary re-renders of consumers. This is especially important for objects and arrays created in the parent.
useRef Use Cases
useRef is more than just a way to access DOM elements. It's perfect for storing any mutable value that doesn't trigger re-renders.
Storing Previous Values
A common pattern is tracking previous props or state values using useRef.
function usePrevious(value: T) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}Testing Hooks
When testing custom Hooks, use @testing-library/react-hooks. This library provides utilities for testing Hooks in isolation without needing to create wrapper components.
Conclusion
React Hooks provide a powerful way to write component logic, but they require understanding their rules and best practices. Follow these guidelines to write cleaner, more maintainable code. Remember: the rules of Hooks exist for a reason, and breaking them leads to bugs that are difficult to track down.
Start with simple patterns, and gradually adopt more advanced techniques as you become comfortable with Hooks basics. The key is to understand why these best practices exist rather than just following them blindly.