← Back to blogReact

Custom hooks I copy between every React project

September 15, 2025·7 min read·2 comments

Over the years I've accumulated a set of custom hooks that I copy into every new React project. They're small, well-tested, and solve problems that come up in every application. None of them are original; most are variations on patterns from the React community. But having them in my own code, with my own API preferences, is faster than adding a hooks library.

useLocalStorage

Syncing state with localStorage is a common requirement for user preferences, form drafts, and feature flags. The native API is straightforward, but there are edge cases: SSR (where localStorage doesn't exist), JSON parsing failures, and keeping multiple tabs in sync.

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
  const [stored, setStored] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue
    try {
      const item = window.localStorage.getItem(key)
      return item ? (JSON.parse(item) as T) : initialValue
    } catch {
      return initialValue
    }
  })

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStored((prev) => {
        const next = value instanceof Function ? value(prev) : value
        if (typeof window !== 'undefined') {
          window.localStorage.setItem(key, JSON.stringify(next))
        }
        return next
      })
    },
    [key]
  )

  return [stored, setValue]
}

Usage:

const [theme, setTheme] = useLocalStorage('theme', 'dark')

The hook handles SSR safely, initialises from localStorage on mount, and writes to localStorage on every update. The API matches useState exactly, so there's no new mental model to learn.

useDebounce

Debouncing is necessary for search inputs, resize handlers, and any value that changes faster than you want to react to it.

function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debounced
}

Usage:

const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)

useEffect(() => {
  if (debouncedQuery) searchAPI(debouncedQuery)
}, [debouncedQuery])

The user types freely, and the search API is called at most once every 300ms. The timeout resets on every keystroke, so only the final value triggers the effect.

useMediaQuery

Responsive behaviour that depends on JavaScript (not just CSS) needs a media query hook:

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false)

  useEffect(() => {
    const mql = window.matchMedia(query)
    setMatches(mql.matches)
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])

  return matches
}

Usage:

const isMobile = useMediaQuery('(max-width: 639px)')
return isMobile ? <MobileNav /> : <DesktopNav />

This handles the initial state and updates reactively when the viewport changes. It's useful for cases where CSS media queries aren't enough, like conditionally rendering entirely different component trees.

useOnClickOutside

Closing dropdown menus and modals when the user clicks outside of them:

function useOnClickOutside(ref: RefObject<HTMLElement | null>, handler: () => void) {
  useEffect(() => {
    function listener(event: MouseEvent | TouchEvent) {
      if (!ref.current || ref.current.contains(event.target as Node)) return
      handler()
    }
    document.addEventListener('mousedown', listener)
    document.addEventListener('touchstart', listener)
    return () => {
      document.removeEventListener('mousedown', listener)
      document.removeEventListener('touchstart', listener)
    }
  }, [ref, handler])
}

Usage:

const menuRef = useRef<HTMLDivElement>(null)
useOnClickOutside(menuRef, () => setOpen(false))

return open ? <div ref={menuRef}><MenuItems /></div> : null

The handler fires on mousedown and touchstart, which feels more responsive than click because it triggers on the press, not the release.

usePrevious

Comparing the current value of a prop or state with its previous value:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined)
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

Usage:

const prevCount = usePrevious(count)
if (prevCount !== undefined && count > prevCount) {
  // count increased
}

This is useful for animations (animate when a value changes), analytics (log when a value crosses a threshold), and conditional effects (only run an effect when a specific prop changes in a specific direction).

useIsFirstRender

Skipping effects on the first render:

function useIsFirstRender(): boolean {
  const isFirst = useRef(true)
  if (isFirst.current) {
    isFirst.current = false
    return true
  }
  return false
}

Usage:

const isFirst = useIsFirstRender()

useEffect(() => {
  if (isFirst) return
  saveToServer(formData)
}, [formData])

This prevents the initial render from triggering a save. Only subsequent changes to formData trigger the effect.

The library question

People ask why I copy these instead of using a library like usehooks-ts or react-use. Three reasons:

  1. No dependency to maintain. These hooks are stable. I haven't changed most of them in two years. A library has releases, changelogs, and breaking changes.

  2. I understand every line. When a hook does something unexpected, I can read the 10 lines of code and understand why. With a library, I have to read the library source.

  3. Exactly the API I want. Libraries make design decisions I don't always agree with. My useDebounce returns the debounced value; some libraries return a debounced function. My useLocalStorage handles SSR; some libraries don't.

The total code for all six hooks is under 100 lines. The maintenance cost is effectively zero. For larger utilities (data fetching, form management, animation), I use libraries. For small, stable patterns, I use my own code.

RESPONSES
Pablo MoralesOct 2, 2025

The argument for copying hooks instead of using a library is convincing. Under 100 lines total, no dependency to maintain, and you understand every line. I've adopted this approach for my last two projects.

Aisha OsmanOct 15, 2025

useOnClickOutside with mousedown instead of click is a subtle but noticeable UX improvement. The menu closes on the press, not the release, which feels snappier. Small detail but it matters.

Leave a response