Master React Hooks with this comprehensive guide covering the most essential hooks and how to create powerful custom hooks. Learn common patterns, best practices, and performance optimization techniques.
React Hooks were introduced in React 16.8 as a way to use state and other React features in functional components. They allow you to "hook into" React state and lifecycle methods without writing class components.
// ❌ Wrong - calling hooks conditionally
function MyComponent({ condition }) {
if (condition) {
const [count, setCount] = useState(0); // This will break!
}
return <div>...</div>;
}
// ✅ Correct - hooks at top level
function MyComponent({ condition }) {
const [count, setCount] = useState(0);
if (condition) {
// Use the state here instead
}
return <div>...</div>;
}
The useState hook lets you add state to functional components. It returns an array with the current state value and a function to update it.
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
function UserProfile() {
const [user, setUser] = useState({
name: '',
email: '',
age: 0
});
const updateName = (name) => {
setUser(prevUser => ({
...prevUser,
name: name
}));
};
const updateUser = (updates) => {
setUser(prevUser => ({
...prevUser,
...updates
}));
};
return (
<form>
<input
value={user.name}
onChange={(e) => updateName(e.target.value)}
placeholder="Name"
/>
<input
value={user.email}
onChange={(e) => updateUser({ email: e.target.value })}
placeholder="Email"
/>
</form>
);
}
function TodoList() {
const [todos, setTodos] = useState([]);
const [inputValue, setInputValue] = useState('');
const addTodo = () => {
if (inputValue.trim()) {
setTodos(prevTodos => [
...prevTodos,
{
id: Date.now(),
text: inputValue,
completed: false
}
]);
setInputValue('');
}
};
const toggleTodo = (id) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
};
return (
<div>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<button onClick={addTodo}>Add Todo</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
When the initial state is expensive to compute, you can pass a function to useState:
function ExpensiveComponent() {
// ❌ This runs on every render
const [data, setData] = useState(expensiveComputation());
// ✅ This only runs once
const [data, setData] = useState(() => expensiveComputation());
return <div>{data}</div>;
}
function expensiveComputation() {
console.log('Computing...');
// Simulate expensive computation
return Array.from({ length: 1000 }, (_, i) => i);
}
The useEffect hook lets you perform side effects in functional components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount combined.
import React, { useState, useEffect } from 'react';
function DocumentTitle() {
const [count, setCount] = useState(0);
// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prevSeconds => prevSeconds + 1);
}, 1000);
// Cleanup function (like componentWillUnmount)
return () => {
clearInterval(interval);
};
}, []); // Empty dependency array means this effect runs once
return <div>Seconds: {seconds}</div>;
}
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!userId) return;
setLoading(true);
fetchUser(userId)
.then(userData => {
setUser(userData);
setLoading(false);
})
.catch(error => {
console.error('Error fetching user:', error);
setLoading(false);
});
}, [userId]); // Effect runs when userId changes
if (loading) return <div>Loading...</div>;
if (!user) return <div>No user found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
function ChatRoom({ roomId, userId }) {
const [messages, setMessages] = useState([]);
const [onlineUsers, setOnlineUsers] = useState([]);
// Effect for subscribing to messages
useEffect(() => {
const messageListener = (message) => {
setMessages(prevMessages => [...prevMessages, message]);
};
ChatAPI.subscribeToMessages(roomId, messageListener);
return () => {
ChatAPI.unsubscribeFromMessages(roomId, messageListener);
};
}, [roomId]);
// Effect for subscribing to online users
useEffect(() => {
const userListener = (users) => {
setOnlineUsers(users);
};
ChatAPI.subscribeToOnlineUsers(roomId, userListener);
return () => {
ChatAPI.unsubscribeFromOnlineUsers(roomId, userListener);
};
}, [roomId]);
// Effect for updating user status
useEffect(() => {
ChatAPI.updateUserStatus(userId, 'online');
return () => {
ChatAPI.updateUserStatus(userId, 'offline');
};
}, [userId]);
return (
<div>
<div>Online Users: {onlineUsers.length}</div>
<div>
{messages.map(message => (
<div key={message.id}>{message.text}</div>
))}
</div>
</div>
);
}
The useContext hook provides a way to pass data through the component tree without manually passing props at every level.
import React, { createContext, useContext, useState } from 'react';
// Create context
const ThemeContext = createContext();
const UserContext = createContext();
// Theme Provider Component
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// User Provider Component
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<UserContext.Provider value={{ user, login, logout }}>
{children}
</UserContext.Provider>
);
}
// Custom hooks for context
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
}
// Component using the context
function Header() {
const { theme, toggleTheme } = useTheme();
const { user, logout } = useUser();
return (
<header style={{
backgroundColor: theme === 'light' ? 'white' : 'black',
color: theme === 'light' ? 'black' : 'white'
}}>
<h1>My App</h1>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} theme
</button>
{user ? (
<div>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</div>
) : (
<div>Please log in</div>
)}
</header>
);
}
// App component
function App() {
return (
<ThemeProvider>
<UserProvider>
<Header />
{/* Other components */}
</UserProvider>
</ThemeProvider>
);
}
The useReducer hook is an alternative to useState for managing complex state logic. It's similar to Redux reducers.
import React, { useReducer } from 'react';
// Reducer function
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload,
completed: false
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
default:
return state;
}
}
function TodoApp() {
const initialState = {
todos: [],
filter: 'all' // 'all', 'active', 'completed'
};
const [state, dispatch] = useReducer(todoReducer, initialState);
const [inputValue, setInputValue] = useState('');
const addTodo = () => {
if (inputValue.trim()) {
dispatch({ type: 'ADD_TODO', payload: inputValue });
setInputValue('');
}
};
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
return (
<div>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<button onClick={addTodo}>Add Todo</button>
<div>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'all' })}
style={{ fontWeight: state.filter === 'all' ? 'bold' : 'normal' }}
>
All
</button>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'active' })}
style={{ fontWeight: state.filter === 'active' ? 'bold' : 'normal' }}
>
Active
</button>
<button
onClick={() => dispatch({ type: 'SET_FILTER', payload: 'completed' })}
style={{ fontWeight: state.filter === 'completed' ? 'bold' : 'normal' }}
>
Completed
</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer'
}}
onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
>
{todo.text}
</span>
<button
onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Custom hooks are JavaScript functions that start with "use" and may call other hooks. They allow you to extract component logic into reusable functions.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Get from local storage then parse stored json or return initialValue
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that persists the new value to localStorage
const setValue = (value) => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'en');
return (
<div>
<label>
Theme:
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Language:
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
</select>
</label>
</div>
);
}
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url, {
...options,
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchData();
return () => {
abortController.abort();
};
}, [url, JSON.stringify(options)]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchInput() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { data: results, loading } = useFetch(
debouncedSearchTerm ? `/api/search?q=${debouncedSearchTerm}` : null
);
return (
<div>
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{loading && <div>Searching...</div>}
{results && (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)}
</div>
);
}
import React, { useState, useMemo } from 'react';
function ExpensiveComponent({ items, filter }) {
const [count, setCount] = useState(0);
// Expensive computation that only runs when items or filter changes
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item => item.category === filter);
}, [items, filter]);
const expensiveValue = useMemo(() => {
console.log('Computing expensive value...');
return filteredItems.reduce((sum, item) => sum + item.value, 0);
}, [filteredItems]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<p>Expensive Value: {expensiveValue}</p>
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
import React, { useState, useCallback, memo } from 'react';
const ChildComponent = memo(({ onClick, name }) => {
console.log(`Rendering ${name}`);
return <button onClick={onClick}>{name}</button>;
});
function ParentComponent() {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(0);
// Without useCallback, this function is recreated on every render
const handleClick1 = () => {
console.log('Button 1 clicked');
};
// With useCallback, this function is only recreated when dependencies change
const handleClick2 = useCallback(() => {
console.log('Button 2 clicked', count);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<p>Other State: {otherState}</p>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
<button onClick={() => setOtherState(otherState + 1)}>Increment Other</button>
{/* This child will re-render when parent re-renders due to new function reference */}
<ChildComponent onClick={handleClick1} name="Button 1 (no useCallback)" />
{/* This child will only re-render when count changes */}
<ChildComponent onClick={handleClick2} name="Button 2 (with useCallback)" />
</div>
);
}
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const execute = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
execute();
}, [execute]);
return { data, loading, error, refetch: execute };
}
function useForm(initialValues, validationRules) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const setValue = (name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const setTouched = (name) => {
setTouched(prev => ({ ...prev, [name]: true }));
};
const validate = () => {
const newErrors = {};
Object.keys(validationRules).forEach(field => {
const rule = validationRules[field];
const value = values[field];
if (rule.required && !value) {
newErrors[field] = `${field} is required`;
} else if (rule.minLength && value.length < rule.minLength) {
newErrors[field] = `${field} must be at least ${rule.minLength} characters`;
} else if (rule.pattern && !rule.pattern.test(value)) {
newErrors[field] = rule.message || `${field} is invalid`;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (onSubmit) => (e) => {
e.preventDefault();
// Mark all fields as touched
const allTouched = Object.keys(initialValues).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {});
setTouched(allTouched);
if (validate()) {
onSubmit(values);
}
};
return {
values,
errors,
touched,
setValue,
setTouched,
handleSubmit,
isValid: Object.keys(errors).length === 0
};
}
// Usage
function ContactForm() {
const { values, errors, touched, setValue, setTouched, handleSubmit } = useForm(
{ name: '', email: '', message: '' },
{
name: { required: true, minLength: 2 },
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email'
},
message: { required: true, minLength: 10 }
}
);
const onSubmit = (formData) => {
console.log('Submitting:', formData);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
type="text"
placeholder="Name"
value={values.name}
onChange={(e) => setValue('name', e.target.value)}
onBlur={() => setTouched('name')}
/>
{touched.name && errors.name && <span style={{color: 'red'}}>{errors.name}</span>}
</div>
<div>
<input
type="email"
placeholder="Email"
value={values.email}
onChange={(e) => setValue('email', e.target.value)}
onBlur={() => setTouched('email')}
/>
{touched.email && errors.email && <span style={{color: 'red'}}>{errors.email}</span>}
</div>
<div>
<textarea
placeholder="Message"
value={values.message}
onChange={(e) => setValue('message', e.target.value)}
onBlur={() => setTouched('message')}
/>
{touched.message && errors.message && <span style={{color: 'red'}}>{errors.message}</span>}
</div>
<button type="submit">Submit</button>
</form>
);
}
// ❌ State too high up
function App() {
const [userInput, setUserInput] = useState('');
return (
<div>
<Header />
<Sidebar />
<SearchForm input={userInput} setInput={setUserInput} />
</div>
);
}
// ✅ State where it's needed
function SearchForm() {
const [userInput, setUserInput] = useState('');
return (
<form>
<input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
/>
</form>
);
}
// ❌ One big state object
function UserForm() {
const [state, setState] = useState({
name: '',
email: '',
loading: false,
errors: {}
});
// Complex updates...
}
// ✅ Separate state variables
function UserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
// Simpler updates...
}
// ❌ Missing dependencies
useEffect(() => {
fetchData(userId);
}, []); // Missing userId dependency
// ❌ Too many dependencies
useEffect(() => {
if (user.id) {
fetchUserData(user.id);
}
}, [user]); // user object might change on every render
// ✅ Correct dependencies
useEffect(() => {
if (user.id) {
fetchUserData(user.id);
}
}, [user.id]); // Only depend on user.id
// ✅ Good custom hook names
function useCounter(initialValue = 0) { /* ... */ }
function useLocalStorage(key, initialValue) { /* ... */ }
function useApi(url) { /* ... */ }
function useDebounce(value, delay) { /* ... */ }
// ❌ Bad naming
function counter() { /* ... */ } // Doesn't start with 'use'
function getData() { /* ... */ } // Not descriptive of hook purpose
// ❌ Problem: Stale closure
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // Always uses initial count value (0)
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{count}</div>;
}
// ✅ Solution: Use functional update
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(prevCount => prevCount + 1); // Uses current count
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{count}</div>;
}
// ❌ Problem: Object in dependency array
function UserProfile({ user }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
fetchProfile(user); // user object changes on every render
}, [user]);
}
// ✅ Solution: Use specific properties
function UserProfile({ user }) {
const [profile, setProfile] = useState(null);
useEffect(() => {
fetchProfile(user);
}, [user.id]); // Only depend on user.id
}
// ❌ Problem: No cleanup
function Component() {
useEffect(() => {
const subscription = api.subscribe(handleData);
// Missing cleanup - memory leak!
}, []);
}
// ✅ Solution: Proper cleanup
function Component() {
useEffect(() => {
const subscription = api.subscribe(handleData);
return () => {
subscription.unsubscribe();
};
}, []);
}
React Hooks provide a powerful and flexible way to manage state and side effects in functional components. Key takeaways:
useState and useEffect for most casesuseMemo and useCallback when you have performance issuesRemember that hooks are just functions, and like any tool, they should be used thoughtfully. Focus on writing clear, maintainable code, and optimize only when necessary.
Happy coding with React Hooks! 🎣