Custom hooks and sharing logic

Naming and the rules of hooks, extracting stateful logic, composing hooks, subscribing to external stores, and testing a hook directly.

Extracting and naming

import { useEffect, useState } from 'react'

// a custom hook is a function whose name starts with "use" and that calls other hooks
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key)
    return stored === null ? initialValue : JSON.parse(stored)
  })

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue]
}

function useToggle(initial = false) {
  const [on, setOn] = useState(initial)
  return { on, toggle: () => setOn(value => !value), setOn }
}

// two components calling the same hook get independent state
function Settings() {
  const [density, setDensity] = useLocalStorage('density', 'comfortable')
  return <select value={density} onChange={e => setDensity(e.target.value)} />
}
  • Hooks must be called at the top level of a component or another hook, in the same order on every render - never inside a condition, a loop or an event handler.
  • A custom hook shares logic, not state. Two components calling useToggle each get their own boolean.
  • The use prefix is not cosmetic: the lint rule uses it to know that the function obeys the rules of hooks, which is what makes the call-order check possible.
  • Return an object when the hook has more than two values, and a tuple when the caller will rename them - both are used widely, so follow the file's existing convention.

Composing hooks

function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value)

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(id)     // cancels the previous timer on every change
  }, [value, delay])

  return debounced
}

// a search hook built from two smaller ones
function useSearch(query) {
  const debounced = useDebounced(query, 250)
  const [state, setState] = useState({ status: 'idle', results: [] })

  useEffect(() => {
    if (debounced === '') {
      setState({ status: 'idle', results: [] })
      return
    }

    const controller = new AbortController()
    setState({ status: 'loading', results: [] })

    search(debounced, controller.signal)
      .then(results => setState({ status: 'ready', results }))
      .catch(error => {
        if (error.name !== 'AbortError') setState({ status: 'error', results: [] })
      })

    return () => controller.abort()
  }, [debounced])

  return state
}
  • Small hooks compose: a debounce hook plus a request hook is easier to test and reuse than one hook that does both.
  • A dependency that is a new object or function on every render restarts the effect every time - depend on the primitive value instead.
  • The cleanup function is where cancellation, timers and subscriptions belong; without it, a hook leaks one of them per mount.
  • A hook that returns a stable API plus changing values reads well as { status, results, refetch }.

External stores and testing a hook

import { useSyncExternalStore } from 'react'

// the store subscribes, React reads; the library owns the state, not the component
function subscribe(onStoreChange) {
  window.addEventListener('online', onStoreChange)
  window.addEventListener('offline', onStoreChange)
  return () => {
    window.removeEventListener('online', onStoreChange)
    window.removeEventListener('offline', onStoreChange)
  }
}

function useOnline() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,      // client snapshot
    () => true                   // server snapshot, for server rendering
  )
}

// the snapshot must be a cached value: returning a new object each call loops forever
let cached = { width: 0 }
function subscribeResize(onStoreChange) {
  const handler = () => {
    cached = { width: window.innerWidth }
    onStoreChange()
  }
  window.addEventListener('resize', handler)
  return () => window.removeEventListener('resize', handler)
}
const useWindowWidth = () => useSyncExternalStore(subscribeResize, () => cached.width)
import { renderHook, act } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { useToggle } from './useToggle'

describe('useToggle', () => {
  test('flips the value', () => {
    const { result } = renderHook(() => useToggle())

    expect(result.current.on).toBe(false)
    act(() => result.current.toggle())
    expect(result.current.on).toBe(true)
  })

  test('accepts an initial value', () => {
    const { result } = renderHook(() => useToggle(true))
    expect(result.current.on).toBe(true)
  })
})
SymptomLikely causeFix
Infinite re-render loopA store snapshot returns a new object each callCache the object or return a primitive
State resets on every renderThe component is defined inside another componentMove it to module scope
Two components do not share the valueA hook was expected to share stateLift the state or use a store
Effect runs on every renderAn unstable dependency (object or function)Memoise it or depend on a primitive
Stale value inside an async callbackThe closure captured an old renderUse the functional update form or a ref
⚠️
A custom hook shares logic, not state. If two components must see the same value, the state has to live above them or in an external store - expecting a hook to behave like a module-level variable is the most common source of duplicated, out-of-sync UI.

FAQ

What actually makes a function a hook?
The use prefix plus the rules of hooks. The prefix is what the linter keys on, and that is what lets React verify the call order and warn about a hook called conditionally.
When do I need useSyncExternalStore?
When state lives outside React - a global store, a browser API, localStorage in another tab. It is the primitive that makes such reads safe with concurrent rendering, including a server snapshot.

Context, refs and escape hatches Performance, memoisation and transitions

Last refreshed 2026-09-18.