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
useToggleeach get their own boolean. - The
useprefix 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)
})
})| Symptom | Likely cause | Fix |
|---|---|---|
| Infinite re-render loop | A store snapshot returns a new object each call | Cache the object or return a primitive |
| State resets on every render | The component is defined inside another component | Move it to module scope |
| Two components do not share the value | A hook was expected to share state | Lift the state or use a store |
| Effect runs on every render | An unstable dependency (object or function) | Memoise it or depend on a primitive |
| Stale value inside an async callback | The closure captured an old render | Use 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.Related
Context, refs and escape hatches Performance, memoisation and transitions
Last refreshed 2026-09-18.