Most custom hook tutorials show you useWindowSize, useLocalStorage, and useDebounce. Those are fine. They exist in every UI library already. What I want to talk about are the hooks that come from pain — the ones you write at 11pm because you've fixed the same bug three times and you're tired of fixing it again.
These are hooks we actually use across our Next.js projects. Some are simple, some are not. All of them exist because we hit a real problem and this was the cleanest solution we found.
useAsyncState — because useState + useEffect for async data is a trap
You've written this pattern a hundred times. Fetch something, set loading true, catch errors, set loading false. It always looks slightly different and it always has a bug. The classic one: component unmounts mid-fetch, you try to setState on an unmounted component, React logs a warning, and you paste a fix from Stack Overflow without really understanding it.
Here's the hook we settled on after iterating through maybe four versions:
import { useState, useEffect, useCallback, useRef } from 'react'
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
export function useAsyncState<T>(
asyncFn: () => Promise<T>,
deps: React.DependencyList = []
) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const run = useCallback(async () => {
setState({ status: 'loading' })
try {
const data = await asyncFn()
if (mountedRef.current) setState({ status: 'success', data })
} catch (err) {
if (mountedRef.current) {
setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) })
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
run()
}, [run])
return { ...state, refetch: run }
}The discriminated union for state is the key part. Instead of juggling three separate booleans (isLoading, isError, data), you get one state object where TypeScript actually helps you. If status is 'success', you know data exists. If it's 'error', you know error exists. No more undefined coercion.
The refetch comes in handy constantly — button that retries on failure, manual refresh, polling. You don't have to restructure anything.
useStableCallback — the one that stops unnecessary re-renders cold
React 19 has useEffectEvent (still experimental at time of writing), but until that stabilizes, you need this. The problem: you want to pass a callback to a child, but the callback closes over props or state that changes. Wrapping it in useCallback means the reference changes every render. Not wrapping it means your effect or memo dependency breaks.
import { useRef, useCallback } from 'react'
export function useStableCallback<T extends (...args: unknown[]) => unknown>(fn: T): T {
const fnRef = useRef(fn)
// Always keep the ref up to date
fnRef.current = fn
// Return a stable function that calls the latest version
return useCallback((...args: unknown[]) => {
return fnRef.current(...args)
}, []) as T
}The reference never changes. The function it calls is always current. This is the pattern React team themselves recommend while useEffectEvent is still cooking. We use this inside any custom hook that accepts a callback — things like useInterval, our WebSocket hook, anything event-driven where you don't want stale closures.
useFormField — because react-hook-form wiring is repetitive
We use react-hook-form on everything. It's great. But the wiring between a form field, its error, its label, and its aria attributes gets copy-pasted constantly. You end up with slightly different aria-describedby values on different forms, missing error IDs, inconsistent accessibility. This hook fixes that by generating consistent IDs and grouping everything together:
import { useId } from 'react'
import { FieldError } from 'react-hook-form'
interface UseFormFieldOptions {
name: string
error?: FieldError
}
export function useFormField({ name, error }: UseFormFieldOptions) {
const id = useId()
const fieldId = `${id}-${name}`
const errorId = `${fieldId}-error`
const descriptionId = `${fieldId}-description`
return {
fieldId,
errorId,
descriptionId,
labelProps: {
htmlFor: fieldId,
},
inputProps: {
id: fieldId,
'aria-describedby': error ? errorId : descriptionId,
'aria-invalid': error ? (true as const) : undefined,
},
errorProps: {
id: errorId,
role: 'alert' as const,
},
descriptionProps: {
id: descriptionId,
},
hasError: !!error,
error,
}
}Small hook, big payoff. Every form field in every project now has proper aria attributes without anyone having to think about it. Accessibility by default, not by checklist. The React useId hook makes server-side rendering safe — no hydration mismatches.
useOptimisticList — for the optimistic updates you keep half-implementing
Optimistic updates are conceptually simple but messy in practice. You want to update the UI immediately, send the request, and roll back on failure. With server actions in Next.js, useOptimistic is available, but it's scoped to a single value and the ergonomics for list operations (add, remove, update) are awkward. So we built this:
import { useState, useCallback } from 'react'
type OptimisticOperation<T> =
| { type: 'add'; item: T }
| { type: 'remove'; id: string | number }
| { type: 'update'; id: string | number; item: Partial<T> }
export function useOptimisticList<T extends { id: string | number }>(
initialItems: T[]
) {
const [items, setItems] = useState<T[]>(initialItems)
const applyOptimistic = useCallback(
async (
operation: OptimisticOperation<T>,
action: () => Promise<void>,
onError?: (err: Error) => void
) => {
const previousItems = items
// Apply optimistically
setItems(current => {
switch (operation.type) {
case 'add':
return [...current, operation.item]
case 'remove':
return current.filter(item => item.id !== operation.id)
case 'update':
return current.map(item =>
item.id === operation.id
? { ...item, ...operation.item }
: item
)
}
})
try {
await action()
} catch (err) {
// Rollback
setItems(previousItems)
onError?.(err instanceof Error ? err : new Error(String(err)))
}
},
[items]
)
return { items, applyOptimistic, setItems }
}The rollback on failure is the bit people forget. You update the UI, the request fails, and the UI is now lying to the user. This handles it automatically. We pass a toast notification as the onError callback most of the time — the user sees the optimistic update, something goes wrong, it rolls back, and they get a toast explaining why.
Optimistic updates without rollback aren't optimistic — they're just wrong. Make rollback a first-class concern from the start.
useInterval — the one everyone writes wrong
Dan Abramov wrote the canonical version of this years ago and it's still the best. We use a version of it that adds pause/resume because polling that runs while a tab is hidden is wasteful, and SSR safety because Next.js will yell at you otherwise:
import { useEffect, useRef } from 'react'
import { useStableCallback } from './useStableCallback'
export function useInterval(
callback: () => void,
delay: number | null,
options: { pauseOnHidden?: boolean } = {}
) {
const { pauseOnHidden = true } = options
const stableCallback = useStableCallback(callback)
const isPausedRef = useRef(false)
useEffect(() => {
if (delay === null) return
const handleVisibility = () => {
if (!pauseOnHidden) return
isPausedRef.current = document.hidden
}
document.addEventListener('visibilitychange', handleVisibility)
const id = setInterval(() => {
if (!isPausedRef.current) stableCallback()
}, delay)
return () => {
clearInterval(id)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [delay, pauseOnHidden, stableCallback])
}Pass null as delay to pause the interval. Pass a number to run it. The stableCallback inside means you never have to worry about the callback being stale. We use this for dashboard polling — refresh data every 30 seconds, but stop when the tab isn't visible.
useMediaQuery — simpler than you think, but get SSR right
Every version of this hook I see online either causes a hydration mismatch or uses a library for something that's ten lines of code. Here's the version that handles SSR without flashing:
import { useState, useEffect } from 'react'
export function useMediaQuery(query: string, defaultValue = false): boolean {
const [matches, setMatches] = useState(() => {
// During SSR, return the default
if (typeof window === 'undefined') return defaultValue
return window.matchMedia(query).matches
})
useEffect(() => {
if (typeof window === 'undefined') return
const mediaQuery = window.matchMedia(query)
setMatches(mediaQuery.matches)
const handler = (event: MediaQueryListEvent) => setMatches(event.matches)
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}, [query])
return matches
}
// Usage:
// const isMobile = useMediaQuery('(max-width: 768px)')
// const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')The defaultValue parameter matters. If you're rendering a sidebar that should be hidden on mobile, pass false. If you're checking prefers-color-scheme and your site defaults to dark, pass true. This prevents layout shift on first render while the client-side JS catches up.
The rule we follow: hooks are for behavior, not just state
The reason most custom hook examples are boring is that they just wrap setState. Useful hooks encapsulate behavior — the full lifecycle of an async operation, the rollback logic for optimistic updates, the cleanup for event listeners. If you're only wrapping two lines of state into a hook, you're probably not gaining much.
A few rules we've landed on after writing a lot of them:
- If you've copy-pasted the same useEffect pattern three times, that's a hook waiting to be extracted.
- Name hooks after what they do, not what they contain. useAsyncState beats useFetchingAndErrorAndLoadingState.
- Always return an object, not an array, unless you're intentionally mimicking useState's [value, setter] pattern. Objects are easier to extend later without breaking callers.
- Make the happy path obvious. The error/loading states should be easy to handle but not mandatory to destructure.
- Test hooks in isolation with @testing-library/react's renderHook. If you can't test it in isolation, it's doing too much.
One thing we've noticed building peal.dev templates: the hooks that survive across multiple projects are always the ones that solve infrastructure problems, not UI problems. Things like async state management, form field wiring, optimistic updates — these come up in every non-trivial app. The useWindowSize hook gets rewritten differently every time because it's not worth standardizing.
The best custom hook is one you write once and never think about again. If you're still tweaking it every project, it's not done yet.
Take these, adapt them to your codebase, break them in your specific ways, and fix them. The point isn't to copy the exact implementation — it's to see the pattern of what makes a hook worth writing. Encapsulate the ugly part, expose a clean interface, handle the failure cases properly. That's it.
