Custom Hooks
Extract and reuse component logic by building your own hooks
Custom hooks let you extract component logic into reusable functions. They compose built-in hooks (useState, useEffect, useRef, etc.) into higher-level abstractions.
The Pattern
A custom hook is a JavaScript function starting with use that calls other hooks. It returns state, functions, or both.
"use client"
import { useState } from "react"
// First custom hook: encapsulates toggle logic
export function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = () => setOn((prev) => !prev)
const setTrue = () => setOn(true)
const setFalse = () => setOn(false)
return { on, toggle, setTrue, setFalse }
}
// Usage
function Accordion() {
const { on: isOpen, toggle } = useToggle()
return (
<div>
<button onClick={toggle}>{isOpen ? "Close" : "Open"}</button>
{isOpen && <p>Content visible</p>}
</div>
)
}
Custom hooks must follow the Rules of Hooks: call hooks unconditionally at the top level, never inside loops, conditions, or nested functions. Your custom hook is still a hook.
useLocalStorage
Persist state to localStorage — reads on mount, writes on change.
"use client"
import { useState, useEffect } from "react"
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key)
return stored ? (JSON.parse(stored) as T) : initial
} catch {
return initial
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (err) {
console.error("Failed to save to localStorage", err)
}
}, [key, value])
return [value, setValue] as const
}
// Usage
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>
)
}
useMediaQuery
Reactively track a CSS media query — useful for responsive logic in JS.
"use client"
import { useState, useEffect } from "react"
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false
return window.matchMedia(query).matches
})
useEffect(() => {
const mql = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [query])
return matches
}
// Usage: compose multiple breakpoints
function ResponsiveLayout() {
const isMobile = useMediaQuery("(max-width: 640px)")
const isTablet = useMediaQuery("(min-width: 641px) and (max-width: 1024px)")
const isDesktop = useMediaQuery("(min-width: 1025px)")
if (isMobile) return <MobileNav />
if (isTablet) return <TabletNav />
return <FullNav />
}
useDebounce
Delay updating a value until after a pause — essential for search-as-you-type inputs.
"use client"
import { useState, useEffect } from "react"
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
// Usage in a search component
function SearchResults() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery.length < 2) return
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`).then((r) =>
r.json()
)
// ... update results state
}, [debouncedQuery])
return <input value={query} onChange={(e) => setQuery(e.target.value)} />
}
Composing Hooks Together
Custom hooks compose naturally. Build higher-level hooks from lower-level ones.
"use client"
// Compose useDebounce + useMediaQuery into a responsive search hook
export function useResponsiveSearch() {
const isMobile = useMediaQuery("(max-width: 640px)")
const debounceMs = isMobile ? 500 : 200 // longer debounce on mobile
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, debounceMs)
return {
query,
setQuery,
debouncedQuery,
isMobile,
}
}
Testing Custom Hooks
Test hooks with renderHook from @testing-library/react.
import { renderHook, act } from "@testing-library/react"
import { useToggle } from "./useToggle"
test("toggles on and off", () => {
const { result } = renderHook(() => useToggle())
expect(result.current.on).toBe(false)
act(() => result.current.toggle())
expect(result.current.on).toBe(true)
act(() => result.current.setFalse())
expect(result.current.on).toBe(false)
})
Use renderHook to test hook behavior in isolation. For async hooks (like
useDebounce), wrap state updates in act() and use waitFor or
jest.useFakeTimers() for time-based logic. Mock window.matchMedia for
useMediaQuery tests.
See It In Action
Build a useWindowSize hook that tracks window dimensions and updates on resize. Then use it in a component that displays "Mobile" or "Desktop" based on a 768px breakpoint.
1"use client"2 3import { useState, useEffect } from "react"4 5function useWindowSize() {6const [windowSize, setWindowSize] = useState({7width: typeof window !== "undefined" ? window.innerWidth : 0,8height: typeof window !== "undefined" ? window.innerHeight : 0,9})10 11useEffect(() => {12function handleResize() {13setWindowSize({14width: window.innerWidth,15height: window.innerHeight,16})17}18 19 window.addEventListener("resize", handleResize)20 handleResize()21 return () => window.removeEventListener("resize", handleResize)22 23}, [])24 25return windowSize26}27 28export default function ResponsiveDisplay() {29const { width, height } = useWindowSize()30const breakpoint = width >= 768 ? "Desktop" : "Mobile"31 32return (33 34<div>35<p>Width: {width}px</p>36<p>Height: {height}px</p>37<p>Breakpoint: {breakpoint}</p>38</div>39)40}Define a useWindowSize hook that listens for window resize events and tracks current width and height. The ResponsiveDisplay component consumes the hook and shows live dimensions plus a Mobile/Desktop label based on a 768px breakpoint.