React Hooks revolutionized how we write components. By allowing functional components to manage state and side effects, Hooks eliminated the complexity of class-based React while opening the door to powerful patterns for code reuse. But many developers only scratch the surface — using useState and useEffect — without exploring the real power of custom hooks.
Custom hooks are the answer to the ancient question: "How do I reuse stateful logic in React?" In this post, I'll show you how to build custom hooks that encapsulate complex logic, reduce component clutter, and make your codebase dramatically more maintainable.
The Problem Custom Hooks Solve
Imagine you have five different components that need to fetch data from an API, handle loading and error states, and cache the result. Without custom hooks, you'd duplicate the logic across all five components. With custom hooks, you extract that logic once and reuse it everywhere.
Custom hooks are functions that start with use and can call other hooks. They encapsulate stateful logic and return values that the component uses. The magic is that each component instance gets its own copy of the state — hooks are not singletons.
Building a useAsync Hook
Let's build a useAsync hook that handles the common pattern of fetching data asynchronously and managing loading/error states:
function useAsync(asyncFunction, immediate = true) {
const [state, setState] = useState({
status: 'idle',
data: null,
error: null,
})
const execute = useCallback(async () => {
setState({ status: 'pending', data: null, error: null })
try {
const response = await asyncFunction()
setState({ status: 'success', data: response, error: null })
} catch (error) {
setState({ status: 'error', data: null, error })
}
}, [asyncFunction])
useEffect(() => {
if (immediate) execute()
}, [execute, immediate])
return { ...state, execute }
}
Now any component can use this hook to fetch data:
function UserProfile({ userId }) {
const { status, data: user, error } = useAsync(() =>
fetch(`/api/users/${userId}`).then(r => r.json())
)
if (status === 'pending') return <div>Loading...</div>
if (status === 'error') return <div>Error: {error.message}</div>
return <div>{user.name}</div>
}
The useFetch Hook with Caching
Real-world fetching needs caching to avoid redundant network requests. Here's a more sophisticated hook that caches results by URL:
const cache = new Map()
function useFetch(url) {
const [state, setState] = useState({
status: 'idle',
data: null,
error: null,
})
useEffect(() => {
if (cache.has(url)) {
setState({ status: 'success', data: cache.get(url), error: null })
return
}
setState({ status: 'pending', data: null, error: null })
fetch(url)
.then(r => r.json())
.then(data => {
cache.set(url, data)
setState({ status: 'success', data, error: null })
})
.catch(error => setState({ status: 'error', data: null, error }))
}, [url])
return state
}
useLocalStorage: Persisting Component State
Many components need to persist state to localStorage. This hook automatically syncs state with localStorage:
function useLocalStorage(key, initialValue) {
const [state, setState] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(error)
return initialValue
}
})
const setValue = useCallback(value => {
try {
const valueToStore = value instanceof Function ? value(state) : value
setState(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(error)
}
}, [key, state])
return [state, setValue]
}
Composing Hooks Together
The true power emerges when you compose multiple hooks. Here's a useUser hook that combines multiple concerns:
function useUser(userId) {
const user = useFetch(`/api/users/${userId}`)
const [favorites, setFavorites] = useLocalStorage('favorites', [])
const toggleFavorite = useCallback(() => {
setFavorites(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
)
}, [userId, setFavorites])
return {
user: user.data,
isFavorite: favorites.includes(userId),
toggleFavorite,
isLoading: user.status === 'pending',
}
}
Testing Custom Hooks
React Testing Library provides renderHook to test custom hooks in isolation:
import { renderHook, act } from '@testing-library/react'
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
Custom hooks are the secret weapon for writing maintainable React. Master them, and your code will become more readable, testable, and resilient to change.