103: Custom Hooks and Advanced Built-in Hooks
Learning objectives
You will learn to:
- extract reusable stateful logic into custom Hooks;
- keep custom Hooks focused on behavior rather than hidden UI;
- design Hook inputs and return values;
- expose debugging information with
useDebugValue; - subscribe to external stores with
useSyncExternalStore; - create stable accessible IDs with
useId; - understand where
useImperativeHandleand layout hooks fit; - avoid “utility Hook” over-abstraction.
The through-line is responsibility. A good Hook gives a component a useful behavior while keeping the lifecycle details, subscriptions, or state transitions in a boundary that can be understood and tested. The goal is not to wrap every function in a Hook. It is to extract logic when doing so makes ownership clearer.
What a custom Hook actually reuses
When developers first encounter custom Hooks, they sometimes describe them as a way to share state. That is not quite right. A custom Hook reuses stateful logic, not the state instance itself.
Each component that calls the Hook receives its own Hook state. The calls share an implementation and behavior, but they remain independent unless the Hook explicitly subscribes to a common external source.
function useDisclosure(initialOpen = false) {
const [open, setOpen] = useState(initialOpen);
function toggle() {
setOpen((current) => !current);
}
return {
open,
setOpen,
toggle,
};
}
Use it twice:
const menu = useDisclosure();
const help = useDisclosure();
menu.open and help.open are separate values. Changing one does not change the other. What is shared is the implementation of the disclosure behavior. That distinction becomes important when deciding whether a problem calls for a custom Hook or a shared store.
Rules of Hooks
Hooks are special because React associates each call with its position in a component's render order. React does not identify a state Hook by the variable name you give its result. It relies on the sequence of calls remaining consistent from render to render.
Call Hooks:
- at the top level of a component;
- at the top level of another Hook.
Do not call them:
- inside conditions;
- inside loops;
- after an early return that is not always taken;
- inside ordinary event handlers.
Wrong:
if (loggedIn) {
const [profile, setProfile] = useState(null);
}
On one render this code may call useState, and on another it may not. That changes the order React uses to associate Hook state with calls. React must be able to match Hook calls between renders, so move the Hook to the component's top level and put the condition around the behavior that uses its result.
Example: reusable request state
For learning purposes, a custom Hook can also package the state machine around a request:
function useResource(url) {
const [state, setState] = useState({
status: 'pending',
data: null,
error: null,
});
useEffect(() => {
const controller = new AbortController();
setState({
status: 'pending',
data: null,
error: null,
});
fetch(url, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(
`HTTP ${response.status}`,
);
}
return response.json();
})
.then((data) => {
setState({
status: 'success',
data,
error: null,
});
})
.catch((error) => {
if (error.name === 'AbortError') {
return;
}
setState({
status: 'error',
data: null,
error,
});
});
return () => {
controller.abort();
};
}, [url]);
return state;
}
This example gives the learner a compact way to see several Hook concerns together: the URL controls the Effect dependency, cleanup aborts work for an old URL, and the returned state describes pending, success, and error outcomes. It is useful to understand how custom Hooks are built, but it is not a recommendation to use this as a production query layer.
Later, TanStack Query v5 is preferred for server state. A real server-state solution also has to make decisions about caching, retries, deduplication, invalidation, pagination, polling, and mutations. Do not rebuild a query-cache library as an exercise in abstraction.
Custom Hook API design
A Hook should reveal its contract clearly. The caller should be able to infer what the Hook owns and what each returned value means without opening several implementation files.
Less clear:
const result = useTaskStuff(id);
Better when the returned responsibilities are obvious:
const {
task,
saveTask,
isSaving,
saveError,
} = useTaskEditor(id);
The second API names the domain behavior directly. Avoid returning a huge bag of unrelated values that effectively becomes a hidden framework. A large return object makes it difficult to tell which values belong together, and it encourages callers to depend on implementation details that the Hook may later need to change.
Hooks should not hide surprising behavior
Naming is part of the API. A Hook named:
useTaskTitle()
should not silently:
- write to localStorage;
- update
document.title; - open a WebSocket;
- register global shortcuts;
- redirect the router.
Those operations have different ownership and lifecycle costs. Names and documentation should make side effects visible. If a Hook really coordinates a subscription or browser effect, say so in the name or in the documented contract rather than making callers discover it by accident.
useId
useId creates a stable ID suitable for accessibility relationships. The important use case is connecting an element such as a label or description to the control it describes:
function Field({
label,
error,
...props
}) {
const id = useId();
const errorId = `${id}-error`;
return (
<div>
<label htmlFor={id}>
{label}
</label>
<input
id={id}
aria-invalid={Boolean(error)}
aria-describedby={
error ? errorId : undefined
}
{...props}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}
The same generated base ID is used for the input and its conditional error message, so the relationship remains correct when the error appears or disappears. Do not use useId for list keys. Keys identify data records and should come from the data; an accessibility ID is not a record identity and is not a substitute for one.
useDebugValue
Library and shared Hook authors can expose useful labels in React DevTools:
function useOnlineStatus() {
const online =
useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
useDebugValue(
online ? 'Online' : 'Offline',
);
return online;
}
This does not change the Hook's runtime result. It adds a useful description for someone inspecting the Hook in DevTools. Do not add useDebugValue to every tiny local Hook; the extra label is most valuable when a Hook is shared, non-obvious, or likely to be inspected while diagnosing application behavior.
useSyncExternalStore
React provides a dedicated primitive for subscribing to external stores. An external store is state whose source of truth is outside React's own state system, such as:
- browser online status;
- media-query store;
- custom client-side store;
- library subscription;
- data source outside React.
function subscribe(callback) {
window.addEventListener(
'online',
callback,
);
window.addEventListener(
'offline',
callback,
);
return () => {
window.removeEventListener(
'online',
callback,
);
window.removeEventListener(
'offline',
callback,
);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true;
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
}
The subscription tells React how to start and stop listening. The snapshot tells React what the current value is, and the server snapshot supplies a value during server rendering. The snapshot returned by getSnapshot must be stable when nothing changed. Do not return a brand-new object every time unless it is cached, because React needs to recognize that the external value is unchanged.
The cleanup function is part of the contract, too. It must remove the same listener or subscription that was installed for that callback. Missing cleanup can leave stale listeners active after a component is gone, while an unstable subscription setup can repeatedly attach and detach listeners during otherwise unrelated renders.
Why not just use Effect + setState?
A manual subscription can work, especially for a narrowly scoped browser integration, but useSyncExternalStore gives React a protocol designed for external stores and server rendering. It describes the relationship among subscribing, reading the current snapshot, and checking for changes.
That protocol avoids important tearing and synchronization problems that library authors would otherwise need to solve themselves. The choice is therefore not just stylistic. If the state truly lives outside React and must be observed safely, use the primitive intended for that boundary.
Custom Hook with an Effect Event
React 19.2 Effect Events can be used inside custom Hooks. They are useful when an Effect should react to one dependency, such as a room ID, while invoking the latest version of a callback without making that callback itself restart the Effect.
function useChatRoom({
roomId,
onMessage,
}) {
const handleMessage =
useEffectEvent(onMessage);
useEffect(() => {
const room = connect(roomId);
room.on('message', (message) => {
handleMessage(message);
});
return () => room.disconnect();
}, [roomId]);
}
Now the connection follows roomId, while the callback always sees the latest values. This prevents a changing callback identity from needlessly disconnecting and reconnecting the room, while still avoiding a stale callback closure.
Advanced Hook inventory
You should know the role of these Hooks even if you do not use all of them daily:
| Hook | Main purpose |
|---|---|
useState | local state |
useReducer | explicit state transitions |
useContext | read context |
useRef | mutable non-render value / DOM |
useEffect | external synchronization |
useEffectEvent | non-reactive event logic inside Effects |
useLayoutEffect | pre-paint layout synchronization |
useInsertionEffect | style-library insertion |
useImperativeHandle | constrained ref API |
useId | accessibility-safe IDs |
useSyncExternalStore | external subscription |
useDebugValue | DevTools label for Hooks |
useMemo | cache calculation when justified |
useCallback | cache function identity when justified |
useTransition | mark non-urgent update |
useDeferredValue | defer non-urgent consumer value |
useActionState | state from an Action |
useOptimistic | optimistic temporary state |
Performance and Action hooks are covered deeply later. For this lesson, the useful distinction is that most of these Hooks solve a specific rendering, synchronization, or library-integration problem. Knowing their purpose is more valuable than using them by reflex.
Two entries deserve a practical boundary here. useImperativeHandle is for exposing a deliberately small imperative API through a ref; it should not become a way for a parent to reach into every detail of a child. useLayoutEffect runs before the browser paints and is appropriate when a measurement or DOM adjustment must be synchronized before the user sees the result. Because it can delay painting, ordinary useEffect remains the default unless pre-paint timing is part of the requirement. useInsertionEffect is even more specialized and is primarily infrastructure for style libraries.
Common mistakes
Hook for every function
A function is only a Hook if it calls Hooks or intentionally participates in Hook composition. Do not rename normal utilities with use. The naming convention communicates that the function obeys the Rules of Hooks and participates in React lifecycle behavior. Misusing it confuses readers and can mislead linting or future maintainers.
Generic useFetch
A generic fetch Hook quickly grows into retries, caching, dedupe, invalidation, pagination, cancellation, polling, and mutations. Each feature introduces behavior that must remain correct across loading transitions, errors, unmounts, and concurrent requests.
Use TanStack Query v5 instead of rebuilding it. A small educational request Hook is useful for learning; a production server-state layer deserves the capabilities and tested semantics of a dedicated library.
Hook returning JSX
Custom Hooks usually return data and behavior. Components return JSX. If a Hook becomes a hidden component tree, reconsider the boundary. The caller should normally decide how the behavior is rendered, while the Hook owns reusable state transitions or synchronization.
External snapshot instability
Wrong:
function getSnapshot() {
return {
online: navigator.onLine,
};
}
This returns a new object every time. Even when navigator.onLine has not changed, the object reference is different, so React cannot treat the snapshot as unchanged. Return a primitive, reuse a cached object, or otherwise provide a stable snapshot representation.
Exercises
- Build
useDisclosure. - Build
useOnlineStatuswithuseSyncExternalStore. - Create a reusable form field using
useId. - Add
useDebugValueto a shared Hook. - Refactor a Hook that hides too many responsibilities into two Hooks.
- Explain why a custom
useFetchis not a replacement for TanStack Query.
Exit questions
- What is shared when two components call the same custom Hook?
- Why must Hook call order remain stable?
- What problem does
useSyncExternalStoresolve? - Why should
useIdnot be used as a list key? - When is
useDebugValueuseful? - What makes a custom Hook API understandable?
Official references
- https://react.dev/learn/reusing-logic-with-custom-hooks
- https://react.dev/reference/react/useId
- https://react.dev/reference/react/useSyncExternalStore
- https://react.dev/reference/react/useDebugValue
- https://react.dev/reference/react/hooks
Deep dive: custom Hooks are behavior modules with lifecycle contracts
A strong custom Hook should let a component say what it needs without exposing low-level synchronization. The Hook is a behavior module: it owns a lifecycle contract, and the component consumes that contract.
Example:
function useKeyboardShortcut(shortcut, onTrigger) {
const onTriggerEvent = useEffectEvent(onTrigger);
useEffect(() => {
function handleKeyDown(event) {
if (matchesShortcut(event, shortcut)) {
event.preventDefault();
onTriggerEvent();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcut]);
}
Consumer:
useKeyboardShortcut('Ctrl+K', () => {
setCommandPaletteOpen(true);
});
The component expresses the feature it wants and does not manage window subscription cleanup. The Hook name communicates a global interaction responsibility, so a reader can reasonably expect it to attach a listener and clean it up when the component's lifecycle ends.
Hook input stability
A Hook API can accidentally make dependencies unstable. Consider:
Weak:
useChat({
roomId,
options: {
reconnect: true,
},
});
If the Hook uses the whole options object as an Effect dependency, the caller creates a new object every render. The Hook may then resubscribe even though the meaningful settings did not change. That is a design problem at the boundary, not a reason to make every caller reach for useMemo.
Better Hook design may accept primitives:
useChat({
roomId,
reconnect: true,
});
or the Hook can extract meaningful primitive fields. Do not force callers to useMemo every config object just to keep your Hook from resubscribing. If a configuration object is genuinely the API, document its identity requirements and compare or normalize the fields that actually control the lifecycle.
Hook return API
Boolean bag:
const {
isOpen,
open,
close,
toggle,
setOpen,
} = useDisclosure();
may be acceptable. The names describe one cohesive behavior and make the caller readable.
But avoid hidden overlap:
const {
state,
data,
value,
result,
current,
thing,
} = useSomething();
This API forces the consumer to inspect the implementation before it can know which value to use. Name return values according to domain behavior. A clear return API is not cosmetic; it limits accidental coupling and makes the Hook easier to evolve.
Reducer inside Hook
function useTaskSelection() {
const [state, dispatch] = useReducer(selectionReducer, {
selectedIds: [],
});
return {
selectedIds: state.selectedIds,
toggle(id) {
dispatch({
type: 'selection/toggled',
id,
});
},
clear() {
dispatch({
type: 'selection/cleared',
});
},
};
}
This hides transition mechanics while keeping the public API domain-oriented. The consumer does not need to know the reducer action names, but the Hook still exposes operations that describe the task-selection behavior. That is a useful place for a reducer: it keeps a set of related transitions explicit without leaking the state machine's plumbing.
useSyncExternalStore deeper example: media query
function subscribeToMediaQuery(query, callback) {
const media = window.matchMedia(query);
media.addEventListener('change', callback);
return () => {
media.removeEventListener('change', callback);
};
}
function createMediaQueryStore(query) {
return {
subscribe(callback) {
return subscribeToMediaQuery(query, callback);
},
getSnapshot() {
return window.matchMedia(query).matches;
},
getServerSnapshot() {
return false;
},
};
}
Here the query is the identity of the external value being observed. The browser owns whether the media query matches; React observes that value through the store contract. In practice, create the store outside render or memoize the infrastructure appropriately so subscriptions do not churn. This demonstrates an external value React does not own, not a replacement for ordinary local UI state.
useId and hydration stability
Do not generate IDs with:
Math.random()
crypto.randomUUID()
during render for label relationships. Server/client could generate different IDs, causing hydration mismatch. Even apart from hydration, generating a new ID during render makes the relationship unstable across renders.
useId produces IDs designed to work with React's rendering/hydration model:
const id = useId();
Use it for:
label↔ input;- help text;
- description IDs;
- ARIA relationships.
Not for database IDs or list keys. Those represent application data and must have a source of identity appropriate to that data.
Hook composition
A feature Hook can compose lower-level Hooks:
function useTaskEditor(task) {
const form = useTaskForm(task);
const online = useOnlineStatus();
const save = useCallback(async () => {
if (!online) {
throw new Error('Offline');
}
return form.submit();
}, [online, form]);
return {
...form,
online,
save,
};
}
This lets the feature-level API combine form behavior with the browser's connectivity state. But be careful: spreading large Hook objects can create unstable object identities and blurred responsibilities. Sometimes returning structured pieces is clearer, especially when consumers should understand which part owns form state and which part represents connectivity.
Do not over-abstract one-off code
If only one component needs:
const [open, setOpen] = useState(false);
creating:
useSettingsSidebarDisclosureStateManager()
adds indirection, not reuse. The abstraction has a name, but it has not created a meaningful boundary if there is no second use and no difficult lifecycle to hide.
Extract when:
- behavior repeats;
- synchronization is non-trivial;
- the component becomes hard to read;
- lifecycle should be hidden behind a tested contract.
The decision is about reducing meaningful complexity, not increasing the number of files or functions. A small local state declaration is often the clearest design when its ownership is obvious.
Hook testing philosophy
Prefer testing a Hook through a realistic component when possible because Hooks exist inside React lifecycle. A component test exercises the way the Hook is actually consumed, including rendering, effects, cleanup, and user-visible behavior.
For a complex shared Hook, renderHook can be useful:
const { result } = renderHook(() => useDisclosure());
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
The test checks the contract rather than React's internal implementation. For either style, include the edge cases that matter to the Hook: cleanup, changing inputs, errors, repeated calls, and external values where applicable. Do not test React itself. Test your behavior and edge cases.
Hook library boundaries
A shared Hook should not silently import feature-specific APIs.
Bad:
shared/hooks/useOnlineStatus
→ imports taskApi
Keep dependency direction clean. A shared browser-status Hook can be used by many features without knowing what a task is. Feature-specific coordination belongs closer to the feature:
Feature Hook:
features/tasks/useTaskRealtime
can import task-domain infrastructure. This boundary keeps reuse honest and prevents a supposedly generic library layer from becoming an indirect feature controller.
Hook error handling
If a Hook requires a provider:
function useAuth() {
const value = useContext(AuthContext);
if (value === null) {
throw new Error('useAuth must be used inside AuthProvider');
}
return value;
}
Failing loudly near development time is better than returning fake fallback auth state. A missing provider is a wiring error, not a normal unauthenticated state. The explicit error points maintainers to the boundary that must be fixed instead of allowing the application to continue with misleading data.
Advanced built-in Hook distinctions
useId
identity for accessibility/hydration.
useDebugValue
DevTools labeling for reusable Hooks.
useSyncExternalStore
external state subscription.
useImperativeHandle
custom imperative ref surface.
useLayoutEffect
pre-paint synchronization.
useInsertionEffect
CSS-in-JS library infrastructure.
useEffectEvent
latest event logic invoked from Effects.
These Hooks solve specific escape-hatch or library problems. They should not dominate everyday component code. Reach for the narrow primitive that matches the boundary you need to integrate, and prefer ordinary rendering, props, state, and Effects when those are sufficient.
Failure clinic
Hook calls another Hook conditionally
Breaks Rules of Hooks. Inspect every branch and early return around the Hook call, then move the call to the top level and conditionally use its result instead.
Hook recreates subscription every render
Often unstable object/function input. Check the Effect dependency list and the identities of configuration values passed into the Hook. The fix may be a better Hook API that extracts primitive dependencies, not another useMemo at every call site.
getSnapshot returns new object each call
Can cause infinite/inconsistent external store updates. A snapshot must represent the same unchanged external value with a stable identity, so return a primitive or a cached object.
Generic useApi
If it grows caching, mutations, retries, invalidation, polling, pagination, it is becoming a poor reimplementation of TanStack Query. Stop and define whether the code is an educational example, a deliberately small domain-specific client, or an accidental server-state library.
Deep-dive exercises
- Build
useKeyboardShortcutusinguseEffectEvent. - Build a media-query external store using
useSyncExternalStore. - Create an accessible field Hook around
useId. - Refactor a Hook API to eliminate unstable config object churn.
- Identify a one-off Hook abstraction that should be deleted.
- Test one custom Hook through a component and through
renderHook, then compare.
Mastery check
Explain:
- what a custom Hook reuses;
- Hook API stability;
- external store snapshots;
- hydration-safe IDs;
- when extracting a Hook improves design;
- why shared Hook dependency direction matters.
Production case study: a custom Hook that coordinates browser state without hiding business state
Build:
function useDocumentVisibility() {
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
}
Implementation:
function subscribe(callback) {
document.addEventListener('visibilitychange', callback);
return () => {
document.removeEventListener('visibilitychange', callback);
};
}
function getSnapshot() {
return document.visibilityState;
}
function getServerSnapshot() {
return 'visible';
}
Consumer:
function QueueStatus() {
const visibility = useDocumentVisibility();
return (
<p>
Window: {visibility}
</p>
);
}
This Hook cleanly wraps a browser external store. It observes document visibility and exposes that value without deciding what a particular business feature should do with it.
The case study also gives you a useful debugging boundary. If the displayed visibility is wrong, first inspect the browser event and document.visibilityState, then inspect the Hook's snapshot and finally the consuming component. That separation makes it possible to determine whether the problem is browser observation, React synchronization, or business behavior rather than treating the whole feature as one opaque effect.
Do not turn it into:
useDocumentVisibilityAndRefreshOrdersAndTrackAnalyticsAndPauseVideo()
Keep browser observation reusable. Business behavior composes it:
const visibility = useDocumentVisibility();
useEffect(() => {
if (visibility === 'visible') {
// only if Query's own focus behavior does not already solve this
}
}, [visibility]);
Before adding that Effect, remember TanStack Query already has focus/refetch policies. Do not duplicate library behavior with custom Hooks. First check whether the existing library configuration already owns the behavior; otherwise the application can end up with duplicate requests or competing lifecycle rules.
Additional depth: building robust Hook contracts
Stable service dependency
Rather than a Hook importing one hard-coded global API:
function useTaskExport() {
import ...
}
a provider can supply a service:
const ServicesContext = createContext(null);
function useServices() {
const services = useContext(ServicesContext);
if (!services) {
throw new Error('ServicesProvider missing');
}
return services;
}
Feature Hook:
function useTaskExport() {
const { taskExporter } = useServices();
return useCallback(
(ids) => taskExporter.export(ids),
[taskExporter],
);
}
This can improve testability for large applications because a test can provide a controlled service implementation. It is unnecessary ceremony for small apps, though. Dependency injection is helpful when it clarifies ownership or makes a meaningful boundary replaceable; it is not automatically better than a direct import.
Hook return stability
If a library consumer relies on referential equality, returning a new object every render matters:
return {
open,
toggle,
};
For ordinary application components this is usually fine. A consumer can still use the individual values, and adding memoization everywhere creates its own cost and complexity.
Do not automatically:
return useMemo(() => ({ open, toggle }), [open, toggle]);
unless the API/consumer actually requires stable identity or compiler/build strategy indicates it. First identify a real identity-sensitive consumer. Optimization should follow a contract or a measured problem, not a general fear of object creation.
Hook naming and side effects
Names should expose side effects.
Compare:
useUser
versus:
useUserPresenceSubscription
If a Hook opens realtime connections, its name/documentation should make lifecycle cost discoverable. A short generic name may be pleasant to type but leaves an important operational fact hidden from code review and from future consumers.
Hook composition ownership
A custom Hook should generally have one lifecycle story. If it:
reads URL
writes localStorage
opens WebSocket
fetches API
manages form draft
it is probably a feature controller hiding too many owners. Each operation has different dependencies, cleanup, failure modes, and likely reuse boundaries. Split around responsibility, then compose at the feature component/provider level. The result may contain more than one Hook, but each Hook's behavior will be easier to name, test, and reason about.
When reviewing a proposed extraction, ask where the source of truth lives, what starts the lifecycle, and what stops it. Then ask whether the public API describes that responsibility or merely hides a group of implementation details. Those questions usually reveal whether the new Hook is a useful behavior module or just a renamed block of component code.
A practical review should also trace the inputs and outputs:
- Which input changes should restart synchronization?
- Which callback should always use current values?
- Which returned values are state, commands, or status?
- What happens when the provider or external source is unavailable?
- What cleanup must happen when the consumer unmounts?
If those answers are difficult to give, the Hook contract is probably carrying too many concerns. Simplifying the boundary before adding optimization or more configuration will usually produce a more maintainable result.
That is the standard to carry into code review: extract behavior when it clarifies ownership, not merely because extraction is possible.
