ReactBuilding Custom Hooks

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:

JSX
// 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:

JSX
// 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>
}
Note
Each component that calls useOnlineStatus gets its own independent state. Custom hooks share logic, not state. If you need two components to share the same state instance, lift that state up or use context.
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, useForm

  • Bad: getWindowSize, windowSizeHook, WindowSize (capitalized but no "use")

  • Bad: use_window_size (underscores are fine technically but break convention)

useWindowSize — A Complete Example

JSX
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 &lt; 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.

JSX
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.

JSX
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>
  )
}
Warning
localStorage is synchronous and can throw (e.g. in private browsing on Safari, or when storage is full). Always wrap reads and writes in try/catch.
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 renderHook from @testing-library/react

  • Readability: components become thinner and more declarative (const size = useWindowSize() vs 15 lines of setup)

  • Composability: hooks can call other hooks — useLocalStorage calls useState, 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

Tip
Do not over-extract. A custom hook with a single useState and no effects is rarely worth it — just inline it. Extract hooks that have real complexity: multiple state variables, effects with cleanup, or non-trivial derived values.
File Organisation

A common project structure places custom hooks in a dedicated hooks/ directory, one hook per file:

Text
src/
  hooks/
    useWindowSize.ts
    useDebounce.ts
    useLocalStorage.ts
    useOnlineStatus.ts
    useMediaQuery.ts
    index.ts          ← re-exports all hooks
Key Takeaways
  • A custom hook is a function starting with use that calls other hooks — nothing more

  • They share logic, not state — each component gets its own independent state instance

  • The use prefix is required for the Rules of Hooks linter to work correctly

  • Extract when the same stateful logic appears in two or more components

  • Custom hooks are pure composition — they add no new React capabilities, only organisation