React Hooks Deep Dive: useState, useEffect, and Beyond
React Hooks revolutionized how we write React components by allowing functional components to manage state and lifecycle methods without the need for class components. Introduced in React 16.8, hooks provide a more direct API to the React concepts you already know: props, state, context, refs, and lifecycle.
This comprehensive guide will explore both core and advanced React hooks, providing practical examples and best practices to help you master modern React development.
Table of Contents
1. [Introduction to React Hooks](#introduction) 2. [Core Hooks](#core-hooks) - [useState](#usestate) - [useEffect](#useeffect) - [useContext](#usecontext) 3. [Advanced Hooks](#advanced-hooks) - [useReducer](#usereducer) - [useMemo](#usememo) - [useCallback](#usecallback) 4. [Best Practices](#best-practices) 5. [Common Patterns and Anti-patterns](#patterns) 6. [Conclusion](#conclusion)Introduction to React Hooks {#introduction}
React Hooks are functions that let you "hook into" React features from functional components. They follow two important rules:
1. Only call hooks at the top level - Don't call hooks inside loops, conditions, or nested functions 2. Only call hooks from React functions - Call them from React functional components or custom hooks
These rules ensure that hooks are called in the same order every time the component renders, which is crucial for React's internal state management.
Core Hooks {#core-hooks}
useState Hook {#usestate}
The useState hook is the most fundamental hook for managing state in functional components. It returns an array with two elements: the current state value and a function to update it.
#### Basic Usage
`javascript
import React, { useState } from 'react';
function Counter() { const [count, setCount] = useState(0);
return (
You clicked {count} times
`#### Multiple State Variables
You can use multiple useState hooks in a single component:
`javascript
function UserProfile() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
return (
); }`#### State with Objects and Arrays
When dealing with objects or arrays, remember that state updates should be immutable:
`javascript
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 removeTodo = (id) => { setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id)); };
return (
-
{todos.map(todo => (
- toggleTodo(todo.id)} > {todo.text} ))}
`#### Functional Updates
When the new state depends on the previous state, use functional updates to avoid stale closure issues:
`javascript
function Counter() {
const [count, setCount] = useState(0);
const incrementAsync = () => { setTimeout(() => { // ✅ Good: Uses functional update setCount(prevCount => prevCount + 1); // ❌ Bad: May use stale count value // setCount(count + 1); }, 1000); };
return (
Count: {count}
`useEffect Hook {#useeffect}
The useEffect hook lets you perform side effects in functional components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount combined in class components.
#### Basic Usage
`javascript
import React, { useState, useEffect } from 'react';
function DocumentTitle() { const [count, setCount] = useState(0);
// Effect runs after every render
useEffect(() => {
document.title = You clicked ${count} times;
});
return (
You clicked {count} times
`#### Effect with Dependencies
Control when effects run by providing a dependency array:
`javascript
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(/api/users/${userId});
const userData = await response.json();
if (!cancelled) {
setUser(userData);
}
} catch (error) {
if (!cancelled) {
console.error('Failed to fetch user:', error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchUser();
// Cleanup function to cancel the request if component unmounts return () => { cancelled = true; }; }, [userId]); // Effect runs when userId changes
if (loading) return
return (
{user.name}
{user.email}
`#### Cleanup Effects
Effects can return a cleanup function that runs when the component unmounts or before the effect runs again:
`javascript
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => { const intervalId = setInterval(() => { setSeconds(prevSeconds => prevSeconds + 1); }, 1000);
// Cleanup function return () => { clearInterval(intervalId); }; }, []); // Empty dependency array means effect runs once on mount
return
`#### Multiple Effects
Separate concerns by using multiple useEffect hooks:
`javascript
function UserDashboard({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
// Effect for fetching user data useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
// Effect for fetching user posts useEffect(() => { fetchUserPosts(userId).then(setPosts); }, [userId]);
// Effect for updating document title
useEffect(() => {
if (user) {
document.title = ${user.name}'s Dashboard;
}
}, [user]);
return (
Welcome, {user.name}!
}`useContext Hook {#usecontext}
The useContext hook provides a way to consume context values without nesting Consumer components. It makes your code cleaner and more readable when working with React Context.
#### Basic Context Setup
`javascript
import React, { createContext, useContext, useState } from 'react';
// Create context const ThemeContext = createContext();
// Theme provider component function ThemeProvider({ children }) { const [theme, setTheme] = useState('light');
const toggleTheme = () => { setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light'); };
const value = { theme, toggleTheme };
return (
// Custom hook for using theme context function useTheme() { const context = useContext(ThemeContext); if (context === undefined) { throw new Error('useTheme must be used within a ThemeProvider'); } return context; }
// Component using the context function Header() { const { theme, toggleTheme } = useTheme();
return (
My App
// App component
function App() {
return (
`
#### Complex Context with Reducer
For more complex state management, combine useContext with useReducer:
`javascript
import React, { createContext, useContext, useReducer } from 'react';
// Auth context and reducer const AuthContext = createContext();
const authReducer = (state, action) => { switch (action.type) { case 'LOGIN_START': return { ...state, loading: true, error: null }; case 'LOGIN_SUCCESS': return { ...state, loading: false, user: action.payload, isAuthenticated: true }; case 'LOGIN_FAILURE': return { ...state, loading: false, error: action.payload, isAuthenticated: false }; case 'LOGOUT': return { ...state, user: null, isAuthenticated: false }; default: return state; } };
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
function AuthProvider({ children }) { const [state, dispatch] = useReducer(authReducer, initialState);
const login = async (credentials) => { dispatch({ type: 'LOGIN_START' }); try { const user = await authenticateUser(credentials); dispatch({ type: 'LOGIN_SUCCESS', payload: user }); } catch (error) { dispatch({ type: 'LOGIN_FAILURE', payload: error.message }); } };
const logout = () => { dispatch({ type: 'LOGOUT' }); };
const value = { ...state, login, logout };
return (
function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
`
Advanced Hooks {#advanced-hooks}
useReducer Hook {#usereducer}
The useReducer hook is an alternative to useState for managing complex state logic. It's particularly useful when you have complex state transitions or when the next state depends on the previous one.
#### Basic useReducer Example
`javascript
import React, { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return initialState;
default:
throw new Error(Unknown action type: ${action.type});
}
}
function Counter() { const [state, dispatch] = useReducer(reducer, initialState);
return (
Count: {state.count}
`#### Complex State Management with useReducer
`javascript
const initialState = {
todos: [],
filter: 'all', // 'all', 'active', 'completed'
nextId: 1
};
function todoReducer(state, action) { switch (action.type) { case 'ADD_TODO': return { ...state, todos: [ ...state.todos, { id: state.nextId, text: action.payload, completed: false } ], nextId: state.nextId + 1 }; 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 }; case 'CLEAR_COMPLETED': return { ...state, todos: state.todos.filter(todo => !todo.completed) }; default: return state; } }
function TodoApp() { 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 => { switch (state.filter) { case 'active': return !todo.completed; case 'completed': return todo.completed; default: return true; } });
return (
-
{filteredTodos.map(todo => (
- dispatch({ type: 'TOGGLE_TODO', payload: todo.id })} > {todo.text} ))}
{state.todos.some(todo => todo.completed) && ( )}
`useMemo Hook {#usememo}
The useMemo hook memoizes expensive calculations and only recalculates when dependencies change. It's useful for optimizing performance when you have expensive computations that don't need to run on every render.
#### Basic useMemo Example
`javascript
import React, { useState, useMemo } from 'react';
function ExpensiveComponent({ items, searchTerm }) { const [sortOrder, setSortOrder] = useState('asc');
// Expensive calculation that we want to memoize const processedItems = useMemo(() => { console.log('Processing items...'); // This will only log when dependencies change let filtered = items.filter(item => item.name.toLowerCase().includes(searchTerm.toLowerCase()) );
return filtered.sort((a, b) => { if (sortOrder === 'asc') { return a.name.localeCompare(b.name); } else { return b.name.localeCompare(a.name); } }); }, [items, searchTerm, sortOrder]); // Dependencies
return (
-
{processedItems.map(item => (
- {item.name} - ${item.price} ))}
`#### Complex Calculation Memoization
`javascript
function DataAnalytics({ data }) {
const [selectedMetric, setSelectedMetric] = useState('revenue');
const analytics = useMemo(() => { const calculations = { revenue: () => data.reduce((sum, item) => sum + item.revenue, 0), averageOrderValue: () => { const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0); return totalRevenue / data.length; }, topProducts: () => { return data .sort((a, b) => b.revenue - a.revenue) .slice(0, 5) .map(item => ({ name: item.name, revenue: item.revenue })); }, monthlyTrend: () => { const monthlyData = {}; data.forEach(item => { const month = new Date(item.date).toLocaleString('default', { month: 'long' }); monthlyData[month] = (monthlyData[month] || 0) + item.revenue; }); return monthlyData; } };
// Only calculate what we need return { [selectedMetric]: calculations[selectedMetric]() }; }, [data, selectedMetric]);
return (
Total Revenue: ${analytics.revenue}
)} {selectedMetric === 'averageOrderValue' && (Average Order Value: ${analytics.averageOrderValue?.toFixed(2)}
)} {selectedMetric === 'topProducts' && (-
{analytics.topProducts?.map((product, index) => (
- {product.name}: ${product.revenue} ))}
-
{Object.entries(analytics.monthlyTrend || {}).map(([month, revenue]) => (
- {month}: ${revenue} ))}
`useCallback Hook {#usecallback}
The useCallback hook memoizes functions and only recreates them when dependencies change. It's particularly useful for preventing unnecessary re-renders of child components that receive functions as props.
#### Basic useCallback Example
`javascript
import React, { useState, useCallback } from 'react';
// Child component that receives a function prop
const TodoItem = React.memo(({ todo, onToggle, onDelete }) => {
console.log(Rendering TodoItem: ${todo.text});
return (
function TodoList() { const [todos, setTodos] = useState([ { id: 1, text: 'Learn React', completed: false }, { id: 2, text: 'Build an app', completed: false } ]);
// Without useCallback, these functions would be recreated on every render const handleToggle = useCallback((id) => { setTodos(prevTodos => prevTodos.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ) ); }, []); // No dependencies because we use functional update
const handleDelete = useCallback((id) => { setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id)); }, []); // No dependencies because we use functional update
return (
-
{todos.map(todo => (
`#### useCallback with Dependencies
`javascript
function SearchableList({ items, onItemSelect }) {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
// This function depends on searchTerm and selectedCategory
const handleSearch = useCallback((term) => {
setSearchTerm(term);
// Perform additional logic that depends on current category
if (selectedCategory !== 'all') {
console.log(Searching for "${term}" in category: ${selectedCategory});
}
}, [selectedCategory]); // Recreate when selectedCategory changes
// This function depends on external prop
const handleItemClick = useCallback((item) => {
onItemSelect(item);
// Additional logic that might depend on current search state
console.log(Selected item: ${item.name} while searching for: ${searchTerm});
}, [onItemSelect, searchTerm]); // Recreate when these change
const filteredItems = useMemo(() => { return items.filter(item => { const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()); const matchesCategory = selectedCategory === 'all' || item.category === selectedCategory; return matchesSearch && matchesCategory; }); }, [items, searchTerm, selectedCategory]);
return (
`Best Practices {#best-practices}
1. Follow the Rules of Hooks
Always call hooks at the top level and only from React functions:
`javascript
// ✅ Good
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
useEffect(() => {
document.title = ${name}: ${count};
});
return
// ❌ Bad - conditional hook usage
function MyComponent({ shouldUseCount }) {
if (shouldUseCount) {
const [count, setCount] = useState(0); // This breaks the rules!
}
}
`
2. Optimize Dependencies
Be careful with dependency arrays in useEffect, useMemo, and useCallback:
`javascript
// ✅ Good - include all dependencies
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => { fetchUser(userId).then(setUser); }, [userId]); // Include userId in dependencies
return
// ✅ Good - use functional updates to avoid dependencies function Counter() { const [count, setCount] = useState(0);
const increment = useCallback(() => { setCount(prev => prev + 1); // No need to include count in dependencies }, []);
return ;
}
`
3. Create Custom Hooks for Reusable Logic
Extract common patterns into custom hooks:
`javascript
// Custom hook for API calls
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => { let cancelled = false;
const fetchData = async () => { try { setLoading(true); const response = await fetch(url); const result = await response.json(); if (!cancelled) { setData(result); setError(null); } } catch (err) { if (!cancelled) { setError(err); } } finally { if (!cancelled) { setLoading(false); } } };
fetchData();
return () => { cancelled = true; }; }, [url]);
return { data, loading, error }; }
// Usage function UserList() { const { data: users, loading, error } = useApi('/api/users');
if (loading) return
return (
-
{users?.map(user => (
- {user.name} ))}
`4. Use the Right Hook for the Job
Choose the appropriate hook based on your needs:
- Use useState for simple state
- Use useReducer for complex state logic
- Use useMemo for expensive calculations
- Use useCallback for function memoization
- Use useEffect for side effects
5. Avoid Premature Optimization
Don't use useMemo and useCallback everywhere. Only use them when you have actual performance issues:
`javascript
// ❌ Unnecessary optimization
function SimpleComponent({ name }) {
const greeting = useMemo(() => Hello, ${name}!, [name]);
return
// ✅ Simple is better
function SimpleComponent({ name }) {
const greeting = Hello, ${name}!;
return
`Common Patterns and Anti-patterns {#patterns}
Pattern: Compound Components with Context
`javascript
const AccordionContext = createContext();
function Accordion({ children, ...props }) {
const [openIndex, setOpenIndex] = useState(null);
return (
function AccordionItem({ index, children }) { const { openIndex, setOpenIndex } = useContext(AccordionContext); const isOpen = openIndex === index; return (
// Usage
function App() {
return (
`
Anti-pattern: Overusing useEffect
`javascript
// ❌ Bad - unnecessary useEffect
function UserGreeting({ user }) {
const [greeting, setGreeting] = useState('');
useEffect(() => {
setGreeting(Hello, ${user.name}!);
}, [user.name]);
return
// ✅ Good - direct calculation
function UserGreeting({ user }) {
const greeting = Hello, ${user.name}!;
return
`Pattern: Error Boundaries with Hooks
`javascript
function useErrorHandler() {
const [error, setError] = useState(null);
const resetError = useCallback(() => {
setError(null);
}, []);
const captureError = useCallback((error) => {
console.error('Captured error:', error);
setError(error);
}, []);
return { error, resetError, captureError };
}
function SafeComponent({ children }) { const { error, resetError, captureError } = useErrorHandler(); if (error) { return (
Something went wrong
{error.message}
`Conclusion {#conclusion}
React Hooks have fundamentally changed how we write React applications, making functional components more powerful and code more reusable. By mastering the core hooks (useState, useEffect, useContext) and advanced hooks (useReducer, useMemo, useCallback), you can build more efficient and maintainable React applications.
Key takeaways:
1. Follow the rules of hooks to ensure consistent behavior
2. Choose the right hook for your specific use case
3. Create custom hooks to encapsulate and reuse stateful logic
4. Optimize judiciously - don't overuse useMemo and useCallback
5. Keep effects focused and separate concerns into multiple effects when needed
6. Use functional updates when the new state depends on the previous state
Remember that hooks are just functions, and like any tool, they're most effective when used appropriately. Start with the basics, understand the patterns, and gradually incorporate more advanced techniques as your applications grow in complexity.
The React ecosystem continues to evolve, and hooks remain at the center of modern React development. By mastering these concepts and patterns, you'll be well-equipped to build robust, performant React applications that scale with your needs.