11 — Custom Hooks
What Custom Hooks Are
custom_hook_basics.js
// A custom hook is a JavaScript function that starts with "use" and may call
// other hooks inside it. It's the React mechanism for REUSING STATEFUL LOGIC
// across components — not state itself, but the logic that manages state.
// The "use" prefix is NOT just convention — the linter (eslint-plugin-react-hooks)
// treats it as a signal that the function follows the Rules of Hooks. Without
// the "use" prefix, the linter won't check it, and hook violations go undetected.
// RULES OF HOOKS (enforced inside custom hooks):
// 1. Only call hooks at the TOP LEVEL — not inside loops, conditions, or nested functions
// 2. Only call hooks from React functions (components or other custom hooks)
Production Hooks
useDebounce
useDebounce.js
import { useState, useEffect } from 'react'
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timeoutId = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timeoutId)
// Cleanup on every value change: resets the timer.
// The consumer only sees the debounced value after the user STOPS
// changing the input for `delay` ms.
}, [value, delay])
return debouncedValue
}
// Usage:
function SearchPage() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 500)
// debouncedQuery lags behind query by 500ms of inactivity
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery)
}, [debouncedQuery]) // API call only fires when user stops typing for 500ms
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
useFetch
useFetch.js
import { useReducer, useEffect } from 'react'
const initialState = { data: null, loading: true, error: null }
function fetchReducer(state, action) {
switch (action.type) {
case 'start': return { data: null, loading: true, error: null }
case 'success': return { data: action.data, loading: false, error: null }
case 'error': return { data: null, loading: false, error: action.error }
default: return state
}
}
function useFetch(url, options) {
const [state, dispatch] = useReducer(fetchReducer, initialState)
useEffect(() => {
if (!url) return
const controller = new AbortController()
dispatch({ type: 'start' })
fetch(url, { ...options, signal: controller.signal })
.then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() })
.then(data => dispatch({ type: 'success', data }))
.catch(err => { if (err.name !== 'AbortError') dispatch({ type: 'error', error: err.message }) })
return () => controller.abort()
}, [url]) // only re-fetch when url changes — NOT when options changes
// CAVEAT: options is usually a new object every render → including it in deps
// would cause an infinite fetch loop. The caller must memoize options if they
// want re-fetching on option changes, or use a JSON stringified dep.
return state
}
useLocalStorage
useLocalStorage.js
import { useState, useEffect, useCallback } from 'react'
function useLocalStorage(key, initialValue) {
// Lazy init: read from localStorage only on mount
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : initialValue
} catch {
return initialValue // localStorage might be unavailable (SSR, privacy mode)
}
})
// Persist to localStorage whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (err) {
console.warn(`Failed to persist ${key}:`, err)
}
}, [key, value])
// Listen for cross-tab changes (storage event fires in other tabs)
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
try { setValue(JSON.parse(e.newValue)) } catch { /* ignore parse errors */ }
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [key])
return [value, setValue]
}
usePrevious
usePrevious.js
import { useRef, useEffect } from 'react'
function usePrevious(value) {
const ref = useRef()
// Update the ref AFTER render so the CURRENT render still sees the old value.
// The effect runs after paint → ref.current holds the value from the PREVIOUS render.
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
// Usage:
function PriceTag({ price }) {
const prevPrice = usePrevious(price)
const trend = prevPrice < price ? '↑' : prevPrice > price ? '↓' : '→'
return <span>{price} {trend}</span>
}
Hook Composition
hook_composition.js
// Custom hooks can call other custom hooks — this is the primary mechanism for
// composing stateful logic. Build complex hooks from simpler ones.
function useUser() {
const { data: user, loading } = useFetch('/api/me')
const prevUser = usePrevious(user)
return { user, prevUser, loading }
}
function useAuth() {
const { user, loading } = useUser()
const [token, setToken] = useLocalStorage('authToken', null)
const isAuthenticated = Boolean(user && token)
const logout = useCallback(() => {
setToken(null)
fetch('/api/logout', { method: 'POST' })
}, [setToken])
return { user, loading, isAuthenticated, token, setToken, logout }
}
// This composition is what makes hooks powerful: complex logic built from
// simple, testable, reusable pieces — without inheritance or render props.
💡 Tips & Tricks
tips.js
// [Idiom] Name hooks with "use" + what they return/manage: useDebounce, useFetch,
// useLocalStorage, useToggle, useMediaQuery. The name should describe the capability.
// [Idiom] Return an object (not an array) when the hook has 3+ values — callers
// can destructure only what they need without worrying about position:
// const { data, loading, error } = useFetch(url) // better than [data, loading, error]
// Return an array for 2-value hooks (value, setter) to match useState convention.
// [Debug] If a custom hook seems to cause stale values, check dependency arrays
// in every internal hook. A missing dep in a nested useEffect/useCallback
// captures stale values silently.
// [Idiom] Keep hooks focused on ONE responsibility. useAuth shouldn't also
// handle theming. Compose small hooks into larger ones rather than building
// one mega-hook.
// [Safety] Guard against SSR — localStorage, window, and document don't exist
// during server rendering. Wrap in try/catch or check typeof window !== 'undefined'.
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] Two components using the same custom hook do NOT share state.
// Each component gets its OWN instance of the hook's useState/useEffect.
// Hooks reuse LOGIC, not STATE. Sharing state requires Context (Ch 7) or
// lifting state up to a common parent.
// [Gotcha] The "use" prefix is enforced by the linter, not React itself.
// A function named "getData" that calls useState won't be caught by the
// linter — and calling it conditionally will silently break the Rules of Hooks.
// [Gotcha] Hooks that accept functions as arguments need those functions to be
// stable (useCallback'd) if they're in a dependency array, or the hook will
// re-run on every render. useFetch(url, fetchFn) → if fetchFn isn't memoized,
// the effect re-runs every render → infinite fetch loop.
// [Gotcha] Returning a function from a custom hook that closes over state can
// create stale closures. Wrap returned functions in useCallback with proper deps.
// [Gotcha] SSR: localStorage, window, and document are undefined during server
// rendering. Accessing them in useState's initializer or useLayoutEffect throws.
// Guard with typeof window !== 'undefined' or use useEffect (which doesn't run on server).
🧠 Spot the Bug
A useToggle hook works on the first click but stops toggling after that:
spot_the_bug.js
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = () => setValue(!value)
return [value, toggle]
}
Answer
toggle captures value from the current render's closure. After the first toggle, value changes and the component re-renders — but if toggle was captured by a consumer (e.g., passed to a memoized child or stored in a variable), the old closure with the old value persists. On the next call, setValue(!value) uses the stale value — toggling back to the original state, then back again, appearing to "stop toggling."
Fix: use the functional updater form so it doesn't depend on the closure:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(prev => !prev), []) // stable + no stale closure
return [value, toggle]
}
The useCallback with [] makes toggle a stable reference (no re-creation), and prev => !prev uses the functional updater so it always has the latest value — no stale closure.
Key Takeaways
key_takeaways.js
// 1. Custom hooks = reusable stateful LOGIC (not state itself). Each component
// using a hook gets its OWN independent state instance. Share STATE via Context.
// 2. The "use" prefix is a linter signal, not just convention. Without it, the
// Rules of Hooks aren't checked — conditional hook calls go undetected.
// 3. Common production hooks: useDebounce (debounce values), useFetch (data
// fetching + cancellation), useLocalStorage (persist + cross-tab sync),
// usePrevious (access prior render's value).
// 4. Compose hooks from other hooks: useAuth → useUser + useLocalStorage.
// Build complex logic from small, focused, testable pieces.
// 5. Functions returned from hooks should be wrapped in useCallback with proper
// deps to prevent stale closures and ensure stable references for consumers.
// Guard against SSR: typeof window !== 'undefined' for browser APIs.