Custom Hook Patterns
Once you understand how to build a custom hook, a set of recurring patterns emerges across virtually every React codebase. This page covers seven of the most useful patterns with full implementations, and points you toward community hook libraries and testing strategies.
1. Data Fetching Hook — useFetch
Encapsulates the classic fetch + loading/error state pattern. Every component that loads remote data needs this; without a hook it is boilerplate repeated everywhere.
import { useState, useEffect, useRef } from 'react'
export function useFetch(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const abortRef = useRef(null)
useEffect(() => {
if (!url) return
abortRef.current?.abort() // cancel previous request
abortRef.current = new AbortController()
setLoading(true)
setError(null)
fetch(url, { signal: abortRef.current.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(json => {
setData(json)
setLoading(false)
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message)
setLoading(false)
}
})
return () => abortRef.current?.abort() // cleanup on unmount
}, [url])
return { data, loading, error }
}
// Usage
function UserProfile({ id }) {
const { data, loading, error } = useFetch(`/api/users/${id}`)
if (loading) return <p>Loading…</p>
if (error) return <p>Error: {error}</p>
return <h2>{data.name}</h2>
}2. Form Hook — useForm
Managing form state manually — tracking every field, handling change events, running validation — is verbose. A useForm hook centralises it:
import { useState, useCallback } from 'react'
export function useForm(initialValues, validate) {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState({})
const [touched, setTouched] = useState({})
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.target
setValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}))
}, [])
const handleBlur = useCallback((e) => {
const { name } = e.target
setTouched(prev => ({ ...prev, [name]: true }))
if (validate) {
setErrors(validate(values))
}
}, [validate, values])
const handleSubmit = useCallback((onSubmit) => (e) => {
e.preventDefault()
const validationErrors = validate ? validate(values) : {}
setErrors(validationErrors)
setTouched(
Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {})
)
if (Object.keys(validationErrors).length === 0) {
onSubmit(values)
}
}, [validate, values])
const reset = useCallback(() => {
setValues(initialValues)
setErrors({})
setTouched({})
}, [initialValues])
return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset }
}
// Usage
function SignupForm() {
const { values, errors, touched, handleChange, handleBlur, handleSubmit } =
useForm(
{ email: '', password: '' },
({ email, password }) => {
const errs = {}
if (!email.includes('@')) errs.email = 'Invalid email'
if (password.length < 8) errs.password = 'Min 8 characters'
return errs
}
)
return (
<form onSubmit={handleSubmit(data => console.log('Submit:', data))}>
<input name="email" value={values.email}
onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <span>{errors.email}</span>}
<button type="submit">Sign Up</button>
</form>
)
}3. Toggle Hook — useToggle
A simple but endlessly useful hook for boolean state. Saves typing useState(false) + a toggle function everywhere:
import { useState, useCallback } from 'react'
export function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, toggle, { setTrue, setFalse }]
}
// Usage
function Modal() {
const [isOpen, toggleOpen, { setFalse: close }] = useToggle(false)
return (
<>
<button onClick={toggleOpen}>Open Modal</button>
{isOpen && (
<dialog open>
<p>Modal content</p>
<button onClick={close}>Close</button>
</dialog>
)}
</>
)
}4. Previous Value — usePrevious
Captures the value from the previous render using a ref. Useful for animations, diffing changes, or showing "changed from X to Y" messages:
import { useRef, useEffect } from 'react'
export function usePrevious(value) {
const ref = useRef(undefined)
useEffect(() => {
ref.current = value // runs AFTER render, so ref.current is always one render behind
})
return ref.current
}
// Usage
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return (
<p>
Now: {count} | Before: {prevCount ?? 'none'}
{count > (prevCount ?? 0) && ' ↑'}
</p>
)
}5. Interval Hook — useInterval
import { useEffect, useRef } from 'react'
export function useInterval(callback, delay) {
const savedCallback = useRef(callback)
// Always keep the ref pointing at the latest callback
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return // passing null pauses the interval
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}
// Usage — self-updating clock
function Clock() {
const [time, setTime] = useState(new Date().toLocaleTimeString())
useInterval(() => setTime(new Date().toLocaleTimeString()), 1000)
return <p>{time}</p>
}6. Media Query — useMediaQuery
import { useState, useEffect } from 'react'
export function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => typeof window !== 'undefined'
? window.matchMedia(query).matches
: false
)
useEffect(() => {
const mq = window.matchMedia(query)
const handler = (e) => setMatches(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [query])
return matches
}
// Usage
function ResponsiveMenu() {
const isMobile = useMediaQuery('(max-width: 768px)')
return isMobile ? <HamburgerMenu /> : <NavBar />
}7. Click Outside — useClickOutside
Detects clicks outside a referenced element — essential for dropdowns, modals, and tooltips that should close when the user clicks elsewhere:
import { useEffect, useRef } from 'react'
export function useClickOutside(handler) {
const ref = useRef(null)
useEffect(() => {
function handleClick(event) {
if (ref.current && !ref.current.contains(event.target)) {
handler(event)
}
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('touchstart', handleClick)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('touchstart', handleClick)
}
}, [handler])
return ref
}
// Usage
function Dropdown({ options }) {
const [isOpen, toggleOpen, { setFalse: close }] = useToggle(false)
const dropdownRef = useClickOutside(close)
return (
<div ref={dropdownRef}>
<button onClick={toggleOpen}>Options</button>
{isOpen && (
<ul>
{options.map(opt => <li key={opt}>{opt}</li>)}
</ul>
)}
</div>
)
}Community Hook Libraries
Library | Package | Highlights |
|---|---|---|
react-use | react-use | 100+ hooks: useBattery, useGeolocation, useIdle, useMouse, useSpeech, and more |
@uidotdev/usehooks | @uidotdev/usehooks | Modern, TypeScript-first hooks with excellent docs |
ahooks | ahooks | Alibaba-maintained; includes advanced async, lifecycle, and DOM hooks |
@mantine/hooks | @mantine/hooks | Companion to Mantine UI; polished and well-tested |
Testing Custom Hooks
Custom hooks are just functions, but because they call React hooks they must run inside a React component. The renderHook utility from @testing-library/react provides a minimal component wrapper for exactly this purpose:
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'
describe('useToggle', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useToggle(false))
expect(result.current[0]).toBe(false)
})
it('toggles on call', () => {
const { result } = renderHook(() => useToggle(false))
act(() => {
result.current[1]() // call toggle()
})
expect(result.current[0]).toBe(true)
})
it('sets to true explicitly', () => {
const { result } = renderHook(() => useToggle(false))
act(() => {
result.current[2].setTrue()
})
expect(result.current[0]).toBe(true)
})
})Key Takeaways
useFetch — encapsulates loading/error/data state and handles abort on cleanup
useForm — centralises field values, validation errors, and touched tracking
useToggle — a clean boolean toggle with named setters
usePrevious — reads the value from the last render using a ref
useInterval — a declarative interval that always calls the latest callback
useMediaQuery — reacts to CSS media query changes
useClickOutside — fires a callback when clicking outside a referenced element
Test hooks in isolation with
renderHookand wrap state mutations inact()