React Custom Hooks: How to Write and Share Your Own
A custom hook is a function starting with 'use' that packages stateful logic for reuse across components. The rules, worked examples (useLocalStorage, useDebounce, useMediaQuery, useOnlineStatus), returning values vs tuples, testing them, and when a hook is the wrong abstraction.
React's built-in hooks — useState, useEffect, useContext — handle state and side effects inside a component. A custom hook is your own function that combines them, so the same logic can be reused in many components.
The rules are simple:
- Its name starts with
use(so React and the linter know it's a hook). - It can call other hooks.
- It follows the rules of hooks: called at the top level, never conditionally. (Invalid hook call)
Each component that uses a hook gets its own state — hooks share logic, not data. (To share data, use Context or a store.)
Example 1: useLocalStorage
State that persists across page reloads:
import { useEffect, useState } from 'react'
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === 'undefined') return initial
try {
const stored = localStorage.getItem(key)
return stored ? (JSON.parse(stored) as T) : initial
} catch {
return initial
}
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue] as const
}
// usage
const [theme, setTheme] = useLocalStorage('theme', 'light')
The typeof window check avoids crashing during server rendering. (window is not defined, localStorage vs cookies)
Example 2: useDebounce
Wait until the user stops typing before searching:
export function useDebounce<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
// usage
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query)
// fetch results when debouncedQuery changes
The cleanup function cancels the previous timer on every keystroke. (React useEffect explained)
Example 3: useMediaQuery
import { useSyncExternalStore } from 'react'
export function useMediaQuery(query: string) {
return useSyncExternalStore(
cb => {
const mql = window.matchMedia(query)
mql.addEventListener('change', cb)
return () => mql.removeEventListener('change', cb)
},
() => window.matchMedia(query).matches,
() => false, // server snapshot
)
}
const isMobile = useMediaQuery('(max-width: 640px)')
useSyncExternalStore is the right built-in for subscribing to anything outside React — browser APIs, stores.
Example 4: useOnlineStatus
export function useOnlineStatus() {
return useSyncExternalStore(
cb => {
window.addEventListener('online', cb)
window.addEventListener('offline', cb)
return () => {
window.removeEventListener('online', cb)
window.removeEventListener('offline', cb)
}
},
() => navigator.onLine,
() => true,
)
}
Designing a good hook
- Return a tuple (
[value, setValue]) when there are one or two things and callers will rename them; return an object when there are several. - Name it for what it does —
useCurrentUser, notuseData. - Keep it focused — one concern per hook.
- Type it with generics so it works for any value. (TypeScript generics)
- Clean up subscriptions, timers and listeners in effect cleanups.
Data fetching hooks
It's tempting to write useFetch with useEffect. For real apps, a library like TanStack Query handles caching, deduplication, retries and race conditions properly, and your custom hooks can wrap it:
export function useInvoices() {
return useQuery({ queryKey: ['invoices'], queryFn: fetchInvoices })
}
Testing hooks
Testing Library's renderHook runs a hook in isolation:
import { renderHook, act } from '@testing-library/react'
const { result } = renderHook(() => useLocalStorage('k', 1))
act(() => result.current[1](2))
expect(result.current[0]).toBe(2)
When not to write a hook
- Logic with no hooks inside should be a plain function, not a hook.
- Don't extract a hook for something used once just to shorten a component — extract when it's reused or genuinely clearer.
EasySpawn runs your React app on its own server with Claude Code, which is good at spotting repeated logic across components and extracting it into tested hooks. See how it works or join the waitlist.
Related: React useEffect Explained · React Context · useMemo vs useCallback · React useState Explained
Keep reading
The Vercel AI SDK: Streaming Chat and Tool Calls in TypeScript
The AI SDK is a TypeScript toolkit for calling language models from any provider with one API, streaming responses to React with useChat, calling tools, and generating structured data. A working Next.js chat with Claude, tool calling with stopWhen, structured output, provider choice, and production concerns.
useMemo vs useCallback (and React.memo): When Memoization Helps
useMemo caches a computed value; useCallback caches a function; React.memo skips re-rendering a component whose props didn't change. When each actually helps, when it's wasted effort, the dependency array rules, and how the React Compiler now does most of this for you.