05 · Custom Hooks¶
When two components need the same stateful behaviour — tracking online status,
syncing with localStorage, debouncing input — you don't share the component; you share
the logic. A custom hook is a function whose name starts with use and which calls
other hooks.
Extracting a hook¶
Two components both track whether the browser is online:
function StatusBar() {
const [online, setOnline] = useState(navigator.onLine)
useEffect(() => {
const on = () => setOnline(true)
const off = () => setOnline(false)
window.addEventListener('online', on)
window.addEventListener('offline', off)
return () => {
window.removeEventListener('online', on)
window.removeEventListener('offline', off)
}
}, [])
return <p>{online ? '✅ Online' : '❌ Offline'}</p>
}
Pull the logic out:
export function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine)
useEffect(() => {
const on = () => setOnline(true)
const off = () => setOnline(false)
window.addEventListener('online', on)
window.addEventListener('offline', off)
return () => {
window.removeEventListener('online', on)
window.removeEventListener('offline', off)
}
}, [])
return online
}
function StatusBar() {
const online = useOnlineStatus()
return <p>{online ? '✅ Online' : '❌ Offline'}</p>
}
function SaveButton({ onSave }) {
const online = useOnlineStatus()
return <button disabled={!online} onClick={onSave}>{online ? 'Save' : 'Reconnecting…'}</button>
}
Each component that calls useOnlineStatus gets its own independent state. Custom
hooks share logic, never state. (To share state, lift it or use context.)
(For subscribing to external sources like this, React also has useSyncExternalStore,
which handles some concurrent-rendering edge cases. The effect version is fine for
learning; Level 3 shows the store version.)
Rules of Hooks¶
- Only call hooks at the top level of a component or custom hook — never inside
conditions, loops, nested functions, or after an early
return. - Only call hooks from React functions — components or other custom hooks, not ordinary utility functions or event handlers.
function Profile({ userId }) {
if (!userId) return null // ❌ early return before a hook
const [tab, setTab] = useState('posts')
...
}
function Profile({ userId }) {
const [tab, setTab] = useState('posts') // ✅ hooks first
if (!userId) return null
...
}
The use prefix is what lets the linter (eslint-plugin-react-hooks) find hook calls
and enforce these rules. A function that doesn't call hooks shouldn't be named use….
A toolbox of useful hooks¶
useLocalStorage¶
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key)
return raw !== null ? JSON.parse(raw) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// storage full or blocked (e.g. private mode) — keep working in memory
}
}, [key, value])
return [value, setValue]
}
// usage — same shape as useState
const [theme, setTheme] = useLocalStorage('theme', 'light')
useDebouncedValue¶
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
function Search() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebouncedValue(query, 400)
// fetch using debouncedQuery (lesson 6), not query
}
useToggle¶
export function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn(v => !v), [])
return [on, toggle, setOn]
}
useCallback keeps toggle the same function across renders, which matters if callers
pass it to memoized children or effect dependencies (lesson 8).
Worked example: useCountdown¶
import { useEffect, useRef, useState } from 'react'
export function useCountdown(seconds, { onDone } = {}) {
const [remaining, setRemaining] = useState(seconds)
const [running, setRunning] = useState(false)
// Keep the latest onDone without restarting the interval when it changes.
const onDoneRef = useRef(onDone)
useEffect(() => {
onDoneRef.current = onDone
})
useEffect(() => {
if (!running) return
const id = setInterval(() => {
setRemaining(r => Math.max(r - 1, 0)) // pure updater: no other side effects in here
}, 1000)
return () => clearInterval(id)
}, [running])
useEffect(() => {
if (running && remaining === 0) {
setRunning(false)
onDoneRef.current?.()
}
}, [running, remaining])
return {
remaining,
running,
start: () => setRunning(true),
pause: () => setRunning(false),
reset: () => {
setRunning(false)
setRemaining(seconds)
},
}
}
function EggTimer() {
const { remaining, running, start, pause, reset } = useCountdown(180, {
onDone: () => alert('Eggs are ready!'),
})
const mm = String(Math.floor(remaining / 60)).padStart(2, '0')
const ss = String(remaining % 60).padStart(2, '0')
return (
<div>
<p style={{ fontSize: '2rem' }}>{mm}:{ss}</p>
{running ? <button onClick={pause}>Pause</button> : <button onClick={start}>Start</button>}
<button onClick={reset}>Reset</button>
</div>
)
}
The "latest ref" trick (onDoneRef) solves a common problem: callers usually pass an
inline onDone arrow function, which is a new function every render. Putting it in the
interval effect's dependencies would restart the timer every render. Storing it in a
ref lets the effect always call the newest callback without depending on it.
Keep updater functions pure, too: React may call them twice in StrictMode, so the
"stop when we hit zero" logic lives in an effect rather than inside setRemaining.
(React 19.2 added useEffectEvent, aimed at exactly this "read the latest callback
from an effect" pattern. On earlier versions, the ref technique above is the standard
approach.)
How It Actually Works¶
There is no special runtime support for custom hooks. React doesn't even know they
exist. When EggTimer calls useCountdown, which calls useState twice, useRef
once and useEffect three times, React simply sees EggTimer making six hook calls in
a row. The custom hook is inlined into the component as far as React is concerned.
React identifies each hook by its position in the call sequence for that component. Internally, the fiber has a linked list of hook objects; each hook call during render moves a cursor one step along the list and reads or writes that node. On the first render the list is built; on every re-render it's walked in the same order.
Now imagine:
If isAdmin flips from true to false, the useState call becomes the second call.
React hands it the node that was the effect's slot — wrong type, wrong data. React
detects many of these mismatches and throws ("Rendered fewer hooks than expected"), but
the root cause is always the same: position is the only identity a hook has. That's
why the Rules of Hooks exist, and why a naming convention (use…) is enough for a
linter to enforce them.
It's also why custom hooks don't share state: each call site creates its own nodes in its own component's list.
Common mistakes¶
- Expecting shared state between two components using the same hook. Each call is independent.
- Conditional hook calls, including after an early return.
- Returning new objects/functions every render from a hook, then callers putting them in effect deps and getting infinite re-runs. Memoize what callers are likely to depend on.
- Naming non-hooks
useSomething, or hooks withoutuse— both confuse the linter. - Over-extracting. A hook used once that just wraps one
useStateadds indirection without value.
Exercise¶
Write and use these hooks:
useMediaQuery(query)→ boolean, usingwindow.matchMediaand itschangeevent, with cleanup. Use it to switch a layout between list and grid at 700px.usePrevious(value)→ the value from the previous render (hint: a ref updated in an effect). Use it to show "▲" or "▼" when a stock price changes.useClickOutside(ref, handler)that callshandlerwhen apointerdownhappens outside the element. Use it to close a dropdown menu.- Deliberately break the Rules of Hooks once (put
usePreviousin anif), observe the lint error and runtime behaviour, then fix it and explain the cause in one sentence.