Table of Contents
- What Are React Hooks?
- Prerequisites
- Core React Hooks
- Custom Hooks: Reusing Stateful Logic
- Best Practices for Using Hooks
- Common Pitfalls to Avoid
- Conclusion
- References
What Are React Hooks?
React Hooks are functions that let you “hook into” React state and lifecycle features from functional components. They were designed to solve three main problems with class components:
- Reusing stateful logic: Class components made it hard to share logic between components (e.g., HOCs and render props led to “wrapper hell”).
- Complex components: Classes often became bloated with lifecycle methods (e.g.,
componentDidMount,componentDidUpdate), mixing unrelated logic. - Confusing
this: Thethiskeyword in classes is error-prone and unintuitive for many developers.
Hooks provide a cleaner, more modular way to write React components. They work with functional components and let you split logic into reusable, testable functions.
Rules of Hooks
To use Hooks correctly, you must follow two core rules (enforced by the ESLint plugin eslint-plugin-react-hooks):
- Only call Hooks at the top level: Don’t call Hooks inside loops, conditions, or nested functions. This ensures Hooks are called in the same order every render.
- Only call Hooks from React functions: Call them from functional components or custom Hooks (never from regular JavaScript functions).
Prerequisites
Before diving into Hooks, ensure you have:
- Basic knowledge of React (components, props, state).
- Familiarity with functional components.
- Understanding of ES6 features (arrow functions, destructuring,
const/let). - React 16.8 or later installed in your project.
Core React Hooks
React provides several built-in Hooks. Let’s explore the most commonly used ones, their use cases, and implementation examples.
useState: Managing State
useState is the most basic Hook. It lets you add state to functional components.
How It Works:
- Takes an initial state value as input.
- Returns an array with two elements: the current state, and a function to update it.
Example: Counter Component
import { useState } from 'react';
function Counter() {
// Declare a state variable "count" with initial value 0
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Breakdown:
useState(0)initializescountto0.setCountis the updater function. When called, it re-renders the component with the newcount.- Unlike class
setState,useStateupdaters replace state (they don’t merge it). For objects/arrays, spread the previous state:const [user, setUser] = useState({ name: 'John', age: 30 }); setUser(prevUser => ({ ...prevUser, age: prevUser.age + 1 })); // Merge updates
useEffect: Handling Side Effects
useEffect lets you perform side effects in functional components (e.g., data fetching, subscriptions, DOM manipulations). It replaces class lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.
How It Works:
- Takes a effect function and an optional dependency array.
- The effect function runs after render.
- The dependency array controls when the effect runs:
- No array: Runs after every render.
- Empty array (
[]): Runs once after mount (likecomponentDidMount). - Array with values: Runs only when those values change (like
componentDidUpdatefor those props/state).
Example 1: Data Fetching on Mount
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Effect function: Fetch user data
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setLoading(false);
}
};
fetchUser();
// Cleanup function (optional): Runs before the component unmounts or re-runs the effect
return () => {
// Cancel pending requests, clear subscriptions, etc.
console.log('Cleanup: UserProfile unmounting or userId changed');
};
}, [userId]); // Re-run effect only if userId changes
if (loading) return <p>Loading...</p>;
if (!user) return <p>User not found</p>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Key Notes:
- The cleanup function prevents memory leaks (e.g., unsubscribing from events).
- Always include all variables used in the effect in the dependency array (React DevTools can warn you about missing dependencies).
useContext: Consuming Context
useContext lets you consume context in a functional component without nesting Context.Consumer.
How It Works:
- Takes a context object (created with
React.createContext). - Returns the current context value for that context.
Example: Theme Context
import { createContext, useContext, useState } from 'react';
// 1. Create a context
const ThemeContext = createContext();
// 2. Provide context at the top level
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Navbar />
<Content />
</ThemeContext.Provider>
);
}
// 3. Consume context in child components
function Navbar() {
const { theme } = useContext(ThemeContext);
return <nav style={{ background: theme === 'light' ? '#fff' : '#333' }}>Navbar</nav>;
}
function Content() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
Why This Works:
useContextavoids “prop drilling” (passing props through intermediate components).- It re-renders the component whenever the context value changes.
useReducer: Complex State Logic
useReducer is an alternative to useState for state logic that involves multiple sub-values or complex transitions (e.g., forms, todo lists). It uses a reducer function to manage state, inspired by Redux.
How It Works:
- Takes a
reducerfunction and aninitialState. - Returns the current state and a
dispatchfunction to trigger state updates.
Example: Todo App
import { useReducer } from 'react';
// Reducer function: Takes current state and action, returns new state
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.text, completed: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
case 'DELETE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []); // Initial state: empty array
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
dispatch({ type: 'ADD_TODO', text }); // Dispatch "ADD_TODO" action
setText('');
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li
key={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
>
{todo.text}
<button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Benefits of useReducer:
- Centralizes state logic, making it easier to test and debug.
- Predictable state transitions via actions.
useRef: Persisting Values & DOM Access
useRef creates a mutable ref object that persists for the lifetime of the component. It has two main use cases:
1. Accessing DOM Elements
import { useRef, useEffect } from 'react';
function TextInput() {
const inputRef = useRef(null); // Initialize with null
useEffect(() => {
// Focus the input on mount
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="text" />;
}
2. Persisting Values Between Renders
useRef values don’t trigger re-renders when updated, unlike state.
import { useRef, useState } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // Persist the interval ID
const startTimer = () => {
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stopTimer = () => {
clearInterval(intervalRef.current); // Access the persisted ID
};
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}
useCallback: Memoizing Functions
useCallback memoizes functions, preventing unnecessary re-creation on every render. This is useful when passing functions to child components that rely on reference equality (e.g., React.memo components).
Example: Optimizing Child Component Re-renders
import { useState, useCallback, memo } from 'react';
// Memoized child component: Only re-renders if props change
const ExpensiveComponent = memo(({ onButtonClick }) => {
console.log('ExpensiveComponent re-rendered');
return <button onClick={onButtonClick}>Click me</button>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
// Memoize the function: Only re-create if dependencies change (none here)
const handleClick = useCallback(() => {
console.log('Button clicked');
}, []); // Empty dependency array: function never re-creates
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<ExpensiveComponent onButtonClick={handleClick} />
</div>
);
}
Why This Works:
- Without
useCallback,handleClickis re-created on every render, causingExpensiveComponentto re-render (sinceonButtonClickprop changes). - With
useCallback,handleClickis memoized, soExpensiveComponentonly renders once.
useMemo: Memoizing Expensive Calculations
useMemo memoizes the result of expensive calculations, re-computing only when dependencies change.
Example: Avoiding Recomputing Expensive Values
import { useState, useMemo } from 'react';
function ExpensiveCalculation({ numbers }) {
// Memoize the result: Only re-calculate if "numbers" changes
const sum = useMemo(() => {
console.log('Calculating sum...');
return numbers.reduce((acc, n) => acc + n, 0);
}, [numbers]); // Re-run only if "numbers" changes
return <p>Sum: {sum}</p>;
}
function ParentComponent() {
const [numbers] = useState([1, 2, 3, 4, 5]);
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment count</button>
<ExpensiveCalculation numbers={numbers} />
</div>
);
}
Key Note:
- Use
useMemoonly for expensive calculations (e.g., sorting large arrays). For trivial logic, it adds unnecessary overhead.
Custom Hooks: Reusing Stateful Logic
Custom Hooks let you extract and reuse stateful logic across multiple components. They are regular JavaScript functions named with the prefix use (to enforce Hooks rules).
Example: useLocalStorage Hook
Let’s create a Hook to sync state with localStorage:
import { useState, useEffect } from 'react';
// Custom Hook: Persists state to localStorage
function useLocalStorage(key, initialValue) {
// Initialize state with localStorage value (or initialValue)
const [value, setValue] = useState(() => {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialValue;
});
// Update localStorage when value changes
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage in a component
function UserSettings() {
const [username, setUsername] = useLocalStorage('username', '');
return (
<div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
/>
<p>Hello, {username || 'Guest'}!</p>
</div>
);
}
Why This Works:
useLocalStorageencapsulates the logic for reading from/writing tolocalStorage.- It can be reused in any component that needs persistent state.
Best Practices for Using Hooks
- Follow the Rules of Hooks: Use the ESLint plugin to enforce them.
- Keep Hooks Focused: Each Hook should handle one piece of logic (e.g., separate
useStatecalls for unrelated state). - Avoid Over-optimizing: Use
useCallback/useMemoonly when necessary (they add overhead). - Name Custom Hooks with
use: This makes them recognizable as Hooks and ensures linter support. - Test Custom Hooks: Use tools like
@testing-library/react-hooksto test logic in isolation.
Common Pitfalls to Avoid
1. Stale Closures
Happens when an effect/function references an outdated state/props value.
Example:
function StaleClosureExample() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log('Count:', count); // Always logs 0 (stale closure)
}, 1000);
return () => clearInterval(interval);
}, []); // Missing "count" in dependencies
return <button onClick={() => setCount(count + 1)}>Increment</button>;
}
Fix: Add count to the dependency array:
useEffect(() => {
const interval = setInterval(() => {
console.log('Count:', count);
}, 1000);
return () => clearInterval(interval);
}, [count]); // Now re-runs when "count" changes
2. Missing Dependencies in useEffect
Always include all variables used in the effect in the dependency array (React DevTools will warn you).
3. Overusing State
Don’t store derived values in state—compute them during render.
Bad:
const [todos, setTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]); // Derived value
useEffect(() => {
setCompletedTodos(todos.filter(todo => todo.completed));
}, [todos]);
Good:
const [todos, setTodos] = useState([]);
const completedTodos = todos.filter(todo => todo.completed); // Compute during render
4. Incorrect Cleanup in useEffect
Cleanup functions must cancel subscriptions/requests to prevent memory leaks.
Example: Canceling a fetch request:
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data', { signal });
// ...
} catch (error) {
if (error.name !== 'AbortError') console.error(error);
}
};
fetchData();
return () => controller.abort(); // Cancel request on unmount/update
}, []);
Conclusion
React Hooks have transformed how we write React components, making stateful logic more reusable, code cleaner, and components easier to maintain. By mastering core Hooks like useState, useEffect, and useContext, and learning to create custom Hooks, you can build powerful, modular React applications.
Remember to follow the Rules of Hooks, avoid common pitfalls like stale closures, and optimize only when necessary. With practice, Hooks will become an indispensable part of your React toolkit.