Custom hooks are one of React's most powerful patterns — they let you extract any stateful logic into a reusable function. Here are 7 production-ready custom hooks you can drop into any project.

useDebounce

Delays updating a value until the user stops typing. Essential for search inputs — prevents firing an API call on every keystroke:

import { useState, useEffect } from 'react'

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

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

  return debounced
}

// Usage
function TemplateSearch() {
  const [search,  setSearch]  = useState('')
  const [results, setResults] = useState<any[]>([])

  const debouncedSearch = useDebounce(search, 400)

  useEffect(() => {
    if (!debouncedSearch) { setResults([]); return }
    fetch(`/api/templates?q=${debouncedSearch}`)
      .then(r => r.json())
      .then(setResults)
  }, [debouncedSearch]) // only fires 400ms after user stops typing

  return (
    <div>
      <input
        value={search}
        onChange={e => setSearch(e.target.value)}
        placeholder="Search templates..."
        className="border-2 rounded-xl px-4 py-2 w-full focus:border-red-400 outline-none"
      />
      <p className="text-sm text-gray-500 mt-1">{results.length} results</p>
    </div>
  )
}

useLocalStorage

Syncs state with localStorage so it persists across page refreshes:

import { useState, useEffect } from 'react'

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch (e) {
      console.warn('localStorage write failed:', e)
    }
  }, [key, value])

  return [value, setValue] as const
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

  useEffect(() => {
    document.documentElement.setAttribute('data-bs-theme', theme)
  }, [theme])

  return (
    <button
      onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
      className={`btn btn-sm ${theme === 'dark' ? 'btn-light' : 'btn-dark'}`}
    >
      {theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
    </button>
  )
}

useFetch

Generic data-fetching hook with loading and error states:

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

interface FetchState<T> {
  data:    T | null
  loading: boolean
  error:   string | null
  refetch: () => void
}

function useFetch<T>(url: string): FetchState<T> {
  const [data,    setData]    = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error,   setError]   = useState<string | null>(null)
  const counter = useRef(0)

  function fetch_() {
    const id = ++counter.current
    setLoading(true)
    setError(null)

    fetch(url)
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
      .then(d => { if (counter.current === id) { setData(d); setLoading(false) } })
      .catch(e => { if (counter.current === id) { setError(e.message); setLoading(false) } })
  }

  useEffect(() => { fetch_() }, [url])

  return { data, loading, error, refetch: fetch_ }
}

// Usage
function TemplateDetail({ id }: { id: number }) {
  const { data: template, loading, error, refetch } = useFetch<{
    name: string; framework: string; price: number
  }>(`/api/templates/${id}`)

  if (loading) return <div className="spinner-border" style={{ color: '#fd4766' }} />
  if (error)   return (
    <div className="alert alert-danger d-flex align-items-center gap-2">
      {error}
      <button className="btn btn-sm btn-outline-danger ms-auto" onClick={refetch}>Retry</button>
    </div>
  )

  return (
    <div className="card border-0 shadow-sm p-4">
      <h2 className="fw-bold">{template!.name}</h2>
      <span className="badge" style={{ background: '#fd4766' }}>{template!.framework}</span>
      <p className="fw-bold mt-2" style={{ color: '#fd4766' }}>${template!.price}</p>
    </div>
  )
}

useMediaQuery

Tracks a CSS media query — useful for showing different UI at different breakpoints:

import { useState, useEffect } from 'react'

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  )

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

  return matches
}

// Usage
function Navbar() {
  const isMobile  = useMediaQuery('(max-width: 767px)')
  const isDark    = useMediaQuery('(prefers-color-scheme: dark)')

  return (
    <nav className="navbar navbar-expand-lg px-4"
      style={{ background: isDark ? '#0d0d0d' : '#fff' }}>
      <a className="navbar-brand fw-bold" href="/">BootstrapPlanet</a>
      {!isMobile && (
        <div className="navbar-nav ms-auto gap-3">
          <a className="nav-link" href="/components">Components</a>
          <a className="nav-link" href="/angular">Angular</a>
        </div>
      )}
      {isMobile && (
        <button className="navbar-toggler">
          <span className="navbar-toggler-icon" />
        </button>
      )}
    </nav>
  )
}

useOnClickOutside

Detects clicks outside an element — for dropdowns, modals, and off-canvas panels:

import { useEffect, RefObject } from 'react'

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

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

// Usage
function Dropdown() {
  const [open, setOpen] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useOnClickOutside(ref, () => setOpen(false))

  return (
    <div ref={ref} className="position-relative d-inline-block">
      <button
        className="btn text-white"
        style={{ background: '#fd4766' }}
        onClick={() => setOpen(o => !o)}
      >
        Templates ▾
      </button>
      {open && (
        <div className="dropdown-menu show shadow border-0 mt-1">
          <a className="dropdown-item" href="/angular">Angular 21</a>
          <a className="dropdown-item" href="/react">React 19</a>
          <a className="dropdown-item" href="/components">Bootstrap 5</a>
        </div>
      )}
    </div>
  )
}

useCopyToClipboard

Handles copy-to-clipboard with a "Copied!" feedback state:

import { useState, useCallback } from 'react'

function useCopyToClipboard(resetDelay = 2000) {
  const [copied, setCopied] = useState(false)

  const copy = useCallback(async (text: string) => {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      setTimeout(() => setCopied(false), resetDelay)
    } catch {
      // Fallback for older browsers
      const el = document.createElement('textarea')
      el.value = text
      document.body.appendChild(el)
      el.select()
      document.execCommand('copy')
      document.body.removeChild(el)
      setCopied(true)
      setTimeout(() => setCopied(false), resetDelay)
    }
  }, [resetDelay])

  return { copied, copy }
}

// Usage
function CodeBlock({ code }: { code: string }) {
  const { copied, copy } = useCopyToClipboard()

  return (
    <div className="position-relative">
      <pre className="bg-dark text-white rounded-3 p-4 mb-0"
        style={{ fontSize: '0.85rem' }}>{code}</pre>
      <button
        onClick={() => copy(code)}
        className="btn btn-sm position-absolute top-0 end-0 m-2"
        style={{
          background: copied ? '#10b981' : '#fd4766',
          color: '#fff',
          border: 'none',
          transition: 'background 0.2s'
        }}
      >
        {copied ? '✓ Copied!' : 'Copy'}
      </button>
    </div>
  )
}

Naming and Rules

Every custom hook must start with use — this isn't just convention, it's how React's linting rules identify hooks and enforce the rules-of-hooks. If your function doesn't start with use, calling useState inside it will either fail silently or throw.

Structure your hooks file by scope:

src/hooks/
├── useDebounce.ts
├── useLocalStorage.ts
├── useFetch.ts
├── useMediaQuery.ts
└── useOnClickOutside.ts

Or co-locate a hook next to the component that owns it if it's not shared. The goal is reusable, testable logic — not necessarily a single global hooks folder.

Frequently Asked Questions

A custom hook is a JavaScript function whose name starts with 'use' and that calls other hooks internally. It lets you extract reusable stateful logic from components so multiple components can share the same behavior without duplicating code.
Create a custom hook when you find yourself copying the same useState/useEffect pattern across multiple components — fetching data by ID, persisting to localStorage, debouncing input, tracking window size. If the logic involves hooks and you use it in more than one place, extract it.
No — each component that calls a custom hook gets its own isolated state. Custom hooks share logic, not state. To share state between components, use Context, Zustand, or lift the state up to a common parent.

Need a Full Bootstrap 5 Admin Dashboard?

Get a complete Angular 21 + Bootstrap 5 dashboard with 50+ components — built by the same team behind BootstrapPlanet.

Browse Templates →

Use code FIRST30 for 30% off your first purchase.

Related Components