React Hooks revolutionized how we write React applications by allowing functional components to use state and lifecycle methods. Since their introduction in React 16.8, hooks have become the preferred way to build React applications. This comprehensive guide will take you through the most important hooks and best practices for using them effectively.
What Are React Hooks?
React Hooks are functions that let you "hook into" React features from functional components. They allow you to use state and other React features without writing a class component. Hooks follow two important rules:
- Only call hooks at the top level of your functions
- Only call hooks from React functions (components or custom hooks)
useState: Managing Component State
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.
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> ); }
Pro tip: When updating state based on the previous state, use the functional update pattern: setCount(prevCount => prevCount + 1). This ensures you're working with the most current state value.
useEffect: Handling Side Effects
The useEffect
hook handles side effects in functional components. It combines the functionality of componentDidMount
, componentDidUpdate
, and componentWillUnmount
from class components.
import React, { useState, useEffect } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { async function fetchUser() { setLoading(true); try { const response = await fetch(`/api/users/${userId}`); const userData = await response.json(); setUser(userData); } catch (error) { console.error('Error fetching user:', error); } finally { setLoading(false); } } fetchUser(); }, [userId]); // Dependency array if (loading) return <div>Loading...</div>; if (!user) return <div>User not found</div>; return <div>Welcome, {user.name}!</div>; }
useEffect Cleanup
For effects that need cleanup (like subscriptions or timers), return a cleanup function from your effect:
useEffect(() => { const timer = setInterval(() => { console.log('Timer tick'); }, 1000); // Cleanup function return () => { clearInterval(timer); }; }, []); // Empty dependency array means this runs once
useContext: Consuming Context
The useContext
hook provides a clean way to consume context values without nesting. It's particularly useful for accessing global state like themes, authentication, or user preferences.
import React, { createContext, useContext, useState } from 'react'; const ThemeContext = createContext(); function App() { const [theme, setTheme] = useState('light'); return ( <ThemeContext.Provider value={{ theme, setTheme }}> <Header /> <Main /> </ThemeContext.Provider> ); } function Header() { const { theme, setTheme } = useContext(ThemeContext); return ( <header className={theme}> <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> Toggle Theme </button> </header> ); }
useReducer: Complex State Management
For more complex state logic, useReducer
is often preferable to useState
. It's especially useful when you have multiple sub-values or when the next state depends on the previous one.
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(); } } function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <div> Count: {state.count} <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> <button onClick={() => dispatch({ type: 'reset' })}>Reset</button> </div> ); }
Custom Hooks: Reusable Logic
Custom hooks allow you to extract component logic into reusable functions. They're just JavaScript functions that start with "use" and can call other hooks.
import { useState, useEffect } from 'react'; // Custom hook for fetching data function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchData() { try { setLoading(true); const response = await fetch(url); const result = await response.json(); setData(result); } catch (err) { setError(err); } finally { setLoading(false); } } fetchData(); }, [url]); return { data, loading, error }; } // Using the custom hook function UserList() { const { data: users, loading, error } = useFetch('/api/users'); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }
Performance Optimization Hooks
useMemo
useMemo
memoizes expensive calculations and only recalculates when dependencies change:
import React, { useMemo } from 'react'; function ExpensiveComponent({ items, filter }) { const filteredItems = useMemo(() => { return items.filter(item => item.category === filter); }, [items, filter]); return ( <ul> {filteredItems.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> ); }
useCallback
useCallback
memoizes functions to prevent unnecessary re-renders of child components:
import React, { useCallback, useState } from 'react'; function Parent() { const [count, setCount] = useState(0); const [name, setName] = useState(''); const handleClick = useCallback(() => { setCount(count + 1); }, [count]); return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> <Child onClick={handleClick} /> </div> ); }
Remember: useMemo and useCallback are optimization tools. Don't use them everywhere—only when you have proven performance issues or expensive calculations.
Best Practices
1. Follow the Rules of Hooks
- Always call hooks at the top level of your function
- Never call hooks inside loops, conditions, or nested functions
- Use the ESLint plugin to enforce these rules
2. Optimize Dependencies
- Be careful with object and function dependencies in useEffect
- Use useCallback and useMemo to stabilize references
- Consider splitting effects with different dependencies
3. Custom Hooks for Reusability
- Extract common logic into custom hooks
- Make custom hooks testable and composable
- Use descriptive names that start with "use"
Common Pitfalls
Stale Closures
Be aware of stale closures when using useEffect with state variables:
// ❌ Problematic - stale closure useEffect(() => { const timer = setInterval(() => { setCount(count + 1); // This might use stale count value }, 1000); return () => clearInterval(timer); }, []); // Missing count dependency // ✅ Better - functional update useEffect(() => { const timer = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); return () => clearInterval(timer); }, []); // No dependencies needed
Conclusion
React Hooks have fundamentally changed how we write React applications. They enable cleaner, more reusable code and make it easier to share stateful logic between components. By mastering these patterns and following best practices, you'll be able to build more maintainable and performant React applications.
The key to becoming proficient with hooks is practice. Start by converting existing class components to functional components with hooks, create custom hooks for common patterns in your applications, and always keep performance considerations in mind.
For more advanced patterns and the latest React updates, check out the official React documentation and stay connected with the React community.