On this page
A custom hook should make a component easier to understand without hiding important behavior. Moving thirty lines into a function named useManager does not automatically create a useful abstraction. The best hooks describe a stable capability, expose a small contract, and make subscriptions, ownership, and failure behavior predictable to their callers.
Start with a concrete contract
Before extracting code, write one sentence describing the capability: observe a media query, synchronize a subscription, or manage a dismissible disclosure. If the sentence lists unrelated responsibilities, keep those responsibilities separate. A hook combining payment submission, page navigation, analytics, and toast presentation is difficult to reuse because every consumer inherits all four policies.
For hooks that consume API records, use JSON to TypeScript with sanitized sample payloads to sketch the input and return types. Review nullable fields and optional properties against the actual service contract. Generated interfaces document assumptions; they do not validate responses at runtime or decide which component should own a request.
Define inputs and outputs before implementation. A disclosure hook might accept an initial state and return the current state plus explicit open and close actions. Avoid returning every internal setter merely because it exists. Small interfaces let implementations change without forcing callers to understand the mechanism. The development hub offers related implementation resources when the abstraction crosses into other concerns.
Share logic, not accidental global state
Each invocation of a custom hook gets its own React state. Calling useDisclosure in two components does not synchronize their expanded values. To share a value, lift state to a common owner, provide it through context, or subscribe to an external store designed for shared ownership.
This distinction is central to state ownership. A hook may wrap access to a shared store, but the store is shared because of where it lives, not because the wrapper's name starts with use. Module-level mutable variables are usually a poor shortcut, especially in server-rendered applications where multiple requests may share a process.
Follow the Rules of Hooks: call hooks at the top level of React components or other hooks, not inside conditional branches or event callbacks. A conditional feature can still use a hook with an enabled input, provided the hook itself remains called consistently and handles the disabled behavior internally.
Do not extract code solely because two components contain similar lines. Similar syntax can conceal different requirements. Wait until the shared behavior has a meaningful contract, then extract that contract rather than forcing both consumers into an increasingly configurable universal hook.
Model external subscriptions explicitly
Browser capabilities are good candidates for focused hooks because they have identifiable subscription and cleanup behavior. The following hook observes one media query using React's external-store API. Its server snapshot deliberately returns false, so consumers must not depend on it for essential server-rendered content.
import { useCallback, useSyncExternalStore } from "react";
const getServerSnapshot = () => false;
export function useMediaQuery(query: string) {
const subscribe = useCallback((notify: () => void) => {
const media = window.matchMedia(query);
media.addEventListener("change", notify);
return () => media.removeEventListener("change", notify);
}, [query]);
const getSnapshot = useCallback(
() => window.matchMedia(query).matches,
[query],
);
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
}The subscription contract is precise: attach a listener for the current query and remove that exact listener when unsubscribing. The snapshot is a boolean, which satisfies the requirement for a stable value when nothing changed. Returning a fresh object on every snapshot read would require caching to avoid repeated updates.
The useSyncExternalStore reference explains server snapshots and hydration expectations. For an actual shared store, the server snapshot must reflect the initial data supplied to the client. The constant false here is a deliberate fallback for a browser-only observation, not a general recipe for hydrating application state.
Prefer CSS media queries when the requirement is purely visual layout. JavaScript observation is more appropriate when behavior genuinely changes. This avoids a post-hydration layout decision and keeps simple styling independent of React execution.
Make effects and asynchronous work honest
An effect should synchronize with something external, not serve as a generic place to calculate values that could be derived during render. Read React's guidance on custom hooks before creating wrappers such as useMount that hide dependencies and make synchronization appear to happen only once.
Dependency arrays describe reactive values used by an effect. Suppressing the lint rule to preserve an old callback often creates stale behavior rather than stability. If an object argument causes repeated work, first ask whether the effect should depend on its primitive fields or whether that object represents a genuinely changing external resource.
For request hooks, specify cancellation and ordering. Aborting a fetch during cleanup prevents unnecessary work, but some asynchronous operations cannot be cancelled. In those cases, ignore results belonging to superseded requests so an older response cannot overwrite newer state. Distinguish an intentional abort from a failure users need to see.
Be explicit about callback identity only when consumers need it. Memoizing every returned function adds complexity without guaranteeing useful performance. Conversely, a callback documented for use in another effect should not change on every render without reason. Treat identity guarantees as part of the public contract and test them when consumers rely on them.
Test behavior through realistic consumers
Test the hook through a small component or an appropriate hook-testing helper. Verify observable behavior rather than inspecting private state slots. For a media-query hook, simulate the initial match, dispatch a change, update the query, and confirm that unmount removes listeners. Include a server-rendering scenario if your application uses server rendering.
Run tests with Strict Mode where practical. Development setup and cleanup checks can reveal subscriptions that are not properly reversible. A test that passes only because an effect runs once is not evidence that the synchronization logic is robust.
Also test two independent consumers. Unless sharing is intentional, updating one should not modify the other. For request hooks, resolve promises out of order and verify that the newest request wins. Check failures and retries, not merely the successful first response.
Document limitations next to usage examples: browser support, server fallback, error policy, and whether the caller owns persistence. Review keyboard and state announcements using the accessible interface guide when a hook drives interactive UI. Reusable logic is valuable only when the resulting interface remains understandable to people using it.
Key takeaways
- Extract capabilities, not miscellaneous component code with a convenient name.
- Each hook invocation owns independent state unless it explicitly subscribes to shared ownership.
- Keep subscriptions reversible and snapshots stable; document server behavior.
- Treat dependencies, cancellation, and result ordering as correctness requirements.
- Test multiple consumers, cleanup, failures, and the contract callers actually depend on.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor