Building Custom Hooks
A custom hook is a JavaScript function whose name starts with use and that calls one or more built-in React hooks internally. That is literally the whole definition. There is no special API, no registration step — custom hooks are pure composition.
Their superpower is extracting stateful logic out of components so it can be reused, tested in isolation, and reasoned about independently from the UI that uses it.
The Motivation: Duplication
Suppose two components both need to know whether the user is online. Without custom hooks you copy the same useState + useEffect block into both:
// Component A
function StatusBanner() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const on = () => setIsOnline(true)
const off = () => setIsOnline(false)
window.addEventListener('online', on)
window.addEventListener('offline', off)
return () => {
window.removeEventListener('online', on)
window.removeEventListener('offline', off)
}
}, [])
return <div>{isOnline ? '🟢 Online' : '🔴 Offline'}</div>
}
// Component B — exact same logic copy-pasted
function SaveButton() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => { /* … same block … */ }, [])
return <button disabled={!isOnline}>Save</button>
}The fix is to extract the logic into a custom hook:
// useOnlineStatus.js
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const on = () => setIsOnline(true)
const off = () => setIsOnline(false)
window.addEventListener('online', on)
window.addEventListener('offline', off)
return () => {
window.removeEventListener('online', on)
window.removeEventListener('offline', off)
}
}, [])
return isOnline
}
// Component A — clean
function StatusBanner() {
const isOnline = useOnlineStatus()
return <div>{isOnline ? '🟢 Online' : '🔴 Offline'}</div>
}
// Component B — clean
function SaveButton() {
const isOnline = useOnlineStatus()
return <button disabled={!isOnline}>Save</button>
}The Naming Convention
The use prefix is mandatory — not just convention. React's linter (eslint-plugin-react-hooks) uses it to know which functions must follow the Rules of Hooks. If you name a function getWindowSize() instead of useWindowSize() and call hooks inside it, the linter will not catch violations and you will get confusing runtime errors.
Good:
useWindowSize,useDebounce,useLocalStorage,useFormBad:
getWindowSize,windowSizeHook,WindowSize(capitalized but no "use")Bad:
use_window_size(underscores are fine technically but break convention)
useWindowSize — A Complete Example
import { useState, useEffect } from 'react'
export function useWindowSize() {
const [size, setSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
})
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, []) // empty deps — only subscribe once
return size
}
// Usage
function Layout() {
const { width } = useWindowSize()
return <div>{width < 768 ? <MobileMenu /> : <DesktopMenu />}</div>
}useDebounce
A debounce hook delays updating a value until the input has stopped changing for a specified time — perfect for search inputs.
import { useState, useEffect } from 'react'
export function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer) // cleanup cancels the pending update
}, [value, delay])
return debouncedValue
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 400)
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery) // only fires 400ms after the user stops typing
}
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} />
}useLocalStorage — Full Implementation
useLocalStorage works exactly like useState but persists the value to localStorage so it survives page refreshes.
import { useState, useCallback } from 'react'
export function useLocalStorage(key, initialValue) {
// Read from localStorage once on first render
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
console.warn(`useLocalStorage: could not read key "${key}"`)
return initialValue
}
})
// Wrap setState to also persist to localStorage
const setValue = useCallback(
(value) => {
try {
// Support functional updates just like useState
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch {
console.warn(`useLocalStorage: could not write key "${key}"`)
}
},
[key, storedValue]
)
return [storedValue, setValue]
}
// Usage — drop-in replacement for useState
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
return (
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
)
}The "No New API" Principle
Custom hooks do not add any new React capability. Everything you can do in a custom hook, you could do by inlining the same code in a component. The value is entirely in organisation and reuse:
Reuse: the same logic works in many components without duplication
Isolation: the hook can be unit-tested with
renderHookfrom@testing-library/reactReadability: components become thinner and more declarative (
const size = useWindowSize()vs 15 lines of setup)Composability: hooks can call other hooks —
useLocalStoragecallsuseState, which is perfectly valid
When to Extract a Custom Hook
A useful rule: extract when you find yourself copying the same useState + useEffect block into a second component. One component — keep it inline. Two components — extract.
Same logic, different components — the canonical reason
The component is getting long and the effect block is conceptually separate — extract for readability even if used once
You want to unit-test the logic independently from the component
The logic has multiple state variables and effects that belong together — e.g. a form with validation
File Organisation
A common project structure places custom hooks in a dedicated hooks/ directory, one hook per file:
src/
hooks/
useWindowSize.ts
useDebounce.ts
useLocalStorage.ts
useOnlineStatus.ts
useMediaQuery.ts
index.ts ← re-exports all hooksKey Takeaways
A custom hook is a function starting with
usethat calls other hooks — nothing moreThey share logic, not state — each component gets its own independent state instance
The
useprefix is required for the Rules of Hooks linter to work correctlyExtract when the same stateful logic appears in two or more components
Custom hooks are pure composition — they add no new React capabilities, only organisation