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.
What a custom Hook actually reuses
A custom Hook reuses stateful logic, not state itself.
Two components calling the same Hook get independent state unless the Hook subscribes to the same 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();
These are independent.
Rules of Hooks
Hooks are special because React associates them with a component's render order.
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);
}
React must be able to match Hook calls between renders.
Example: reusable request state
For learning purposes:
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 is useful to understand custom Hooks, but later TanStack Query v5 is preferred for server state.
Do not rebuild a query-cache library as an exercise in abstraction.
Custom Hook API design
A Hook should reveal its contract clearly.
Less clear:
const result = useTaskStuff(id);
Better when the returned responsibilities are obvious:
const {
task,
saveTask,
isSaving,
saveError,
} = useTaskEditor(id);
Avoid returning a huge bag of unrelated values that effectively becomes a hidden framework.
Hooks should not hide surprising behavior
A Hook named:
useTaskTitle()
should not silently:
- write to localStorage;
- update
document.title; - open a WebSocket;
- register global shortcuts;
- redirect the router.
Names and documentation should make side effects visible.
useId
useId creates a stable ID suitable for accessibility relationships:
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>
);
}
Do not use useId for list keys.
Keys identify data records and should come from the data.
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;
}
Do not add useDebugValue to every tiny local Hook.
useSyncExternalStore
React provides a dedicated primitive for subscribing to external stores.
Examples:
- 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 snapshot returned by getSnapshot must be stable when nothing changed.
Do not return a brand-new object every time unless it is cached.
Why not just use Effect + setState?
A manual subscription can work, but useSyncExternalStore gives React a protocol designed for external stores and server rendering.
It avoids important tearing and synchronization problems library authors would otherwise need to solve.
Custom Hook with an Effect Event
React 19.2 Effect Events can be used inside custom Hooks.
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.
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.
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.
Generic useFetch
A generic fetch Hook quickly grows into retries, caching, dedupe, invalidation, pagination, cancellation, polling, and mutations.
Use TanStack Query v5 instead of rebuilding it.
Hook returning JSX
Custom Hooks usually return data and behavior. Components return JSX.
If a Hook becomes a hidden component tree, reconsider the boundary.
External snapshot instability
Wrong:
function getSnapshot() {
return {
online: navigator.onLine,
};
}
This returns a new object every time.
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.
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 does not manage window subscription cleanup.
The Hook name communicates a global interaction responsibility.
Hook input stability
A Hook API can accidentally make dependencies unstable.
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.
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.
Hook return API
Boolean bag:
const {
isOpen,
open,
close,
toggle,
setOpen,
} = useDisclosure();
may be acceptable.
But avoid hidden overlap:
const {
state,
data,
value,
result,
current,
thing,
} = useSomething();
Name return values according to domain behavior.
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.
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;
},
};
}
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.
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.
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.
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,
};
}
But be careful: spreading large Hook objects can create unstable object identities and blurred responsibilities.
Sometimes returning structured pieces is clearer.
Do not over-abstract one-off code
If only one component needs:
const [open, setOpen] = useState(false);
creating:
useSettingsSidebarDisclosureStateManager()
adds indirection, not reuse.
Extract when:
- behavior repeats;
- synchronization is non-trivial;
- the component becomes hard to read;
- lifecycle should be hidden behind a tested contract.
Hook testing philosophy
Prefer testing a Hook through a realistic component when possible because Hooks exist inside React lifecycle.
For a complex shared Hook, renderHook can be useful:
const { result } = renderHook(() => useDisclosure());
act(() => {
result.current.open();
});
expect(result.current.isOpen).toBe(true);
But 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.
Feature Hook:
features/tasks/useTaskRealtime
can import task-domain infrastructure.
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.
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/library problems. They should not dominate everyday component code.
Failure clinic
Hook calls another Hook conditionally
Breaks Rules of Hooks.
Hook recreates subscription every render
Often unstable object/function input.
getSnapshot returns new object each call
Can cause infinite/inconsistent external store updates.
Generic useApi
If it grows caching, mutations, retries, invalidation, polling, pagination, it is becoming a poor reimplementation of TanStack Query.
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.
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.
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, though it is unnecessary ceremony for small apps.
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.
Do not automatically:
return useMemo(() => ({ open, toggle }), [open, toggle]);
unless the API/consumer actually requires stable identity or compiler/build strategy indicates it.
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.
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.
Split around responsibility, then compose at the feature component/provider level.
