ReactCustom Hook Patterns

Custom Hook Patterns

Once you understand how to build a custom hook, a set of recurring patterns emerges across virtually every React codebase. This page covers seven of the most useful patterns with full implementations, and points you toward community hook libraries and testing strategies.

1. Data Fetching Hook — useFetch

Encapsulates the classic fetch + loading/error state pattern. Every component that loads remote data needs this; without a hook it is boilerplate repeated everywhere.

JSX
import { useState, useEffect, useRef } from 'react'

export function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  const abortRef = useRef(null)

  useEffect(() => {
    if (!url) return
    abortRef.current?.abort()                     // cancel previous request
    abortRef.current = new AbortController()

    setLoading(true)
    setError(null)

    fetch(url, { signal: abortRef.current.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        setData(json)
        setLoading(false)
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message)
          setLoading(false)
        }
      })

    return () => abortRef.current?.abort()        // cleanup on unmount
  }, [url])

  return { data, loading, error }
}

// Usage
function UserProfile({ id }) {
  const { data, loading, error } = useFetch(`/api/users/${id}`)

  if (loading) return <p>Loading…</p>
  if (error) return <p>Error: {error}</p>
  return <h2>{data.name}</h2>
}
Note
For production apps prefer TanStack Query (React Query) or SWR over a hand-rolled useFetch — they handle caching, background refetching, pagination, and optimistic updates. useFetch is great for learning and small projects.
2. Form Hook — useForm

Managing form state manually — tracking every field, handling change events, running validation — is verbose. A useForm hook centralises it:

JSX
import { useState, useCallback } from 'react'

export function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues)
  const [errors, setErrors] = useState({})
  const [touched, setTouched] = useState({})

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target
    setValues(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }))
  }, [])

  const handleBlur = useCallback((e) => {
    const { name } = e.target
    setTouched(prev => ({ ...prev, [name]: true }))
    if (validate) {
      setErrors(validate(values))
    }
  }, [validate, values])

  const handleSubmit = useCallback((onSubmit) => (e) => {
    e.preventDefault()
    const validationErrors = validate ? validate(values) : {}
    setErrors(validationErrors)
    setTouched(
      Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {})
    )
    if (Object.keys(validationErrors).length === 0) {
      onSubmit(values)
    }
  }, [validate, values])

  const reset = useCallback(() => {
    setValues(initialValues)
    setErrors({})
    setTouched({})
  }, [initialValues])

  return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset }
}

// Usage
function SignupForm() {
  const { values, errors, touched, handleChange, handleBlur, handleSubmit } =
    useForm(
      { email: '', password: '' },
      ({ email, password }) => {
        const errs = {}
        if (!email.includes('@')) errs.email = 'Invalid email'
        if (password.length &lt; 8) errs.password = 'Min 8 characters'
        return errs
      }
    )

  return (
    <form onSubmit={handleSubmit(data => console.log('Submit:', data))}>
      <input name="email" value={values.email}
        onChange={handleChange} onBlur={handleBlur} />
      {touched.email && errors.email && <span>{errors.email}</span>}
      <button type="submit">Sign Up</button>
    </form>
  )
}
3. Toggle Hook — useToggle

A simple but endlessly useful hook for boolean state. Saves typing useState(false) + a toggle function everywhere:

JSX
import { useState, useCallback } from 'react'

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => setValue(v => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])

  return [value, toggle, { setTrue, setFalse }]
}

// Usage
function Modal() {
  const [isOpen, toggleOpen, { setFalse: close }] = useToggle(false)

  return (
    <>
      <button onClick={toggleOpen}>Open Modal</button>
      {isOpen && (
        <dialog open>
          <p>Modal content</p>
          <button onClick={close}>Close</button>
        </dialog>
      )}
    </>
  )
}
4. Previous Value — usePrevious

Captures the value from the previous render using a ref. Useful for animations, diffing changes, or showing "changed from X to Y" messages:

JSX
import { useRef, useEffect } from 'react'

export function usePrevious(value) {
  const ref = useRef(undefined)

  useEffect(() => {
    ref.current = value   // runs AFTER render, so ref.current is always one render behind
  })

  return ref.current
}

// Usage
function Counter() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)

  return (
    <p>
      Now: {count} | Before: {prevCount ?? 'none'}
      {count > (prevCount ?? 0) && ' ↑'}
    </p>
  )
}
5. Interval Hook — useInterval

JSX
import { useEffect, useRef } from 'react'

export function useInterval(callback, delay) {
  const savedCallback = useRef(callback)

  // Always keep the ref pointing at the latest callback
  useEffect(() => {
    savedCallback.current = callback
  }, [callback])

  useEffect(() => {
    if (delay === null) return          // passing null pauses the interval
    const id = setInterval(() => savedCallback.current(), delay)
    return () => clearInterval(id)
  }, [delay])
}

// Usage — self-updating clock
function Clock() {
  const [time, setTime] = useState(new Date().toLocaleTimeString())
  useInterval(() => setTime(new Date().toLocaleTimeString()), 1000)
  return <p>{time}</p>
}
6. Media Query — useMediaQuery

JSX
import { useState, useEffect } from 'react'

export function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => typeof window !== 'undefined'
      ? window.matchMedia(query).matches
      : false
  )

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

  return matches
}

// Usage
function ResponsiveMenu() {
  const isMobile = useMediaQuery('(max-width: 768px)')
  return isMobile ? <HamburgerMenu /> : <NavBar />
}
7. Click Outside — useClickOutside

Detects clicks outside a referenced element — essential for dropdowns, modals, and tooltips that should close when the user clicks elsewhere:

JSX
import { useEffect, useRef } from 'react'

export function useClickOutside(handler) {
  const ref = useRef(null)

  useEffect(() => {
    function handleClick(event) {
      if (ref.current && !ref.current.contains(event.target)) {
        handler(event)
      }
    }

    document.addEventListener('mousedown', handleClick)
    document.addEventListener('touchstart', handleClick)
    return () => {
      document.removeEventListener('mousedown', handleClick)
      document.removeEventListener('touchstart', handleClick)
    }
  }, [handler])

  return ref
}

// Usage
function Dropdown({ options }) {
  const [isOpen, toggleOpen, { setFalse: close }] = useToggle(false)
  const dropdownRef = useClickOutside(close)

  return (
    <div ref={dropdownRef}>
      <button onClick={toggleOpen}>Options</button>
      {isOpen && (
        <ul>
          {options.map(opt => <li key={opt}>{opt}</li>)}
        </ul>
      )}
    </div>
  )
}
Community Hook Libraries

Library

Package

Highlights

react-use

react-use

100+ hooks: useBattery, useGeolocation, useIdle, useMouse, useSpeech, and more

@uidotdev/usehooks

@uidotdev/usehooks

Modern, TypeScript-first hooks with excellent docs

ahooks

ahooks

Alibaba-maintained; includes advanced async, lifecycle, and DOM hooks

@mantine/hooks

@mantine/hooks

Companion to Mantine UI; polished and well-tested

Tip
Before writing a custom hook, search these libraries first — there is a good chance the hook already exists with edge cases handled that you would miss.
Testing Custom Hooks

Custom hooks are just functions, but because they call React hooks they must run inside a React component. The renderHook utility from @testing-library/react provides a minimal component wrapper for exactly this purpose:

JSX
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'

describe('useToggle', () => {
  it('starts with the initial value', () => {
    const { result } = renderHook(() => useToggle(false))
    expect(result.current[0]).toBe(false)
  })

  it('toggles on call', () => {
    const { result } = renderHook(() => useToggle(false))

    act(() => {
      result.current[1]()   // call toggle()
    })

    expect(result.current[0]).toBe(true)
  })

  it('sets to true explicitly', () => {
    const { result } = renderHook(() => useToggle(false))

    act(() => {
      result.current[2].setTrue()
    })

    expect(result.current[0]).toBe(true)
  })
})
Note
Always wrap state-changing calls in act() when testing hooks — this tells React Testing Library to flush all effects and state updates before assertions.
Key Takeaways
  • useFetch — encapsulates loading/error/data state and handles abort on cleanup

  • useForm — centralises field values, validation errors, and touched tracking

  • useToggle — a clean boolean toggle with named setters

  • usePrevious — reads the value from the last render using a ref

  • useInterval — a declarative interval that always calls the latest callback

  • useMediaQuery — reacts to CSS media query changes

  • useClickOutside — fires a callback when clicking outside a referenced element

  • Test hooks in isolation with renderHook and wrap state mutations in act()