The Hooks Most Developers Miss
useState, useEffect, and useRef cover 80% of use cases. The other hooks in React's standard library solve specific problems so cleanly that reinventing them manually is always worse.
1. useReducer — Complex State Logic
When useState becomes an unwieldy mess of related state variables:
// Before: 4 useState calls that should move together
const [status, setStatus] = useState('idle')
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [retries, setRetries] = useState(0)
// After: useReducer with discriminated union state
type State =
| { status: 'idle' }
| { status: 'loading'; retries: number }
| { status: 'success'; data: User }
| { status: 'error'; error: Error; retries: number }
type Action =
| { type: 'fetch' }
| { type: 'success'; data: User }
| { type: 'error'; error: Error }
| { type: 'retry' }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'fetch': return { status: 'loading', retries: 0 }
case 'success': return { status: 'success', data: action.data }
case 'error': return { status: 'error', error: action.error, retries: 0 }
case 'retry': return { status: 'loading', retries: state.status === 'error' ? state.retries + 1 : 0 }
}
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' })
2. useId — Unique, Stable IDs
Generating IDs for accessibility attributes (linking labels to inputs) used to require Math.random() or libraries:
// useId generates a stable, unique ID — even with SSR
function FormField({ label, ...props }) {
const id = useId()
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} {...props} />
</div>
)
}
// Safe to render multiple instances — each gets a unique ID
<FormField label="Email" type="email" />
<FormField label="Name" type="text" />
Unlike Math.random(), useId produces the same ID on server and client — no hydration mismatch.
3. useDeferredValue — Non-Urgent Updates
Keep the UI responsive while expensive re-renders catch up:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<div style={{ opacity: isStale ? 0.6 : 1 }}>
{/* ExpensiveList only re-renders after the deferred value updates */}
<ExpensiveList query={deferredQuery} />
</div>
)
}
The input updates instantly; the expensive list re-renders after the browser is free. No debouncing required.
4. useTransition — Mark Updates as Non-Urgent
function TabPanel() {
const [tab, setTab] = useState('home')
const [isPending, startTransition] = useTransition()
function handleTabClick(tab) {
startTransition(() => {
setTab(tab) // React can interrupt this if something more urgent comes in
})
}
return (
<div>
<TabButton onClick={() => handleTabClick('posts')} pending={isPending}>
Posts
</TabButton>
<TabContent tab={tab} />
</div>
)
}
Updates inside startTransition are interruptible. If the user clicks another tab before the first renders, React abandons the first render.
5. useImperativeHandle — Controlled Ref Exposure
When you build a component that needs to expose an imperative API:
interface VideoPlayerRef {
play: () => void
pause: () => void
seekTo: (seconds: number) => void
}
const VideoPlayer = forwardRef<VideoPlayerRef, Props>((props, ref) => {
const videoRef = useRef<HTMLVideoElement>(null)
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seekTo: (s) => { if (videoRef.current) videoRef.current.currentTime = s },
}))
return <video ref={videoRef} {...props} />
})
// Usage — typed and controlled
const playerRef = useRef<VideoPlayerRef>(null)
playerRef.current?.seekTo(30)
The parent gets only the API you expose — not the raw DOM element.
6. useSyncExternalStore — Subscribe to External Data
The right way to subscribe to stores, browser APIs, or any external data source:
// Subscribe to online/offline status
function useIsOnline() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
},
() => navigator.onLine, // getSnapshot (client)
() => true, // getServerSnapshot (SSR)
)
}
// Works with Redux, Zustand, or any external store
function useCounterStore() {
return useSyncExternalStore(
counterStore.subscribe,
counterStore.getSnapshot,
)
}
7. useLayoutEffect — Synchronous DOM Reads
Like useEffect, but fires synchronously after DOM updates and before the browser paints:
function Tooltip({ target, children }) {
const tooltipRef = useRef(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
useLayoutEffect(() => {
// Measure the target and tooltip BEFORE browser paints
// This prevents the tooltip from flashing in the wrong position
const targetRect = target.getBoundingClientRect()
const tooltipRect = tooltipRef.current.getBoundingClientRect()
setPosition({
top: targetRect.top - tooltipRect.height,
left: targetRect.left,
})
}, [target])
return (
<div ref={tooltipRef} style={position}>
{children}
</div>
)
}
Use useLayoutEffect when you need to measure DOM elements and reposition before paint. Use useEffect for everything else.
Key Takeaways
useReducer— complex state with multiple related values and transitionsuseId— SSR-safe unique IDs for accessibilityuseDeferredValue— keep input responsive while expensive renders catch upuseTransition— mark state updates as non-urgent and interruptibleuseImperativeHandle— expose a controlled API from child to parent via refsuseSyncExternalStore— subscribe to external data sources safelyuseLayoutEffect— synchronous DOM measurement before browser paint