Blog
3 min read

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:

  1. Its name starts with use (so React and the linter know it's a hook).
  2. It can call other hooks.
  3. 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, not useData.
  • 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 })
}

(TanStack Query vs useEffect)

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)

(Vitest vs Jest)

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