You add a search box to a page. A timer delays the search until the user stops typing.
Then another page needs the same behavior. You copy the timer, the effect, and the cleanup.
A few weeks later, someone changes the delay in one place. Another search box behaves differently. A third forgets to clear its timer.
The problem isn't that the code is long. It's that the same behavior now has several owners.
That's where a custom hook helps.
In this article, we'll build five hooks for situations that show up in real applications:
| Hook | Coordinates | Primary Real-World Use Case |
|---|---|---|
⏱️ useDebouncedValue |
Input value & timer | Delaying network searches & heavy validation |
🌐 useOnlineStatus |
Browser connectivity store | Showing "Offline" alerts & pausing remote sync |
📱 useMediaQuery |
Media query matches | Adapting layout logic & honoring prefers-reduced-motion
|
💾 useLocalStorageState |
React state & storage | Persisting drafts, filters, and UI preferences safely |
👁️ useInView |
DOM element & IntersectionObserver
|
Scroll-triggered animations, lazy widgets, & impression tracking |
We'll also cover when a hook is the wrong abstraction.
🧩 First: what belongs in a custom hook?
A custom hook is a function that uses React hooks to package reusable behavior. Its name starts with use:
function useSomething() {
// React hooks can be used here.
}
But adding use to a function name doesn't make it a useful hook.
If you're calculating a total, use a regular function:
function calculateTotal(items) {
return items.reduce(
(total, item) => total + item.price,
0
);
}
If you're managing a timer, subscribing to a browser event, or coordinating state and cleanup, a hook may be appropriate.
A useful hook usually has a clear contract:
Given these inputs, expose this state or behavior, and manage its lifecycle.
For example:
const delayedSearch = useDebouncedValue(search, 350);
The component shouldn't need to know how the timer is created or cleaned up.
One important distinction: custom hooks share logic, not automatically state. Two calls to a hook containing useState normally have independent state. Hooks subscribing to the same external source can observe the same underlying data. React's custom hook guide
Call hooks at the top level of a component or another hook, not inside conditions, loops, or event handlers.
The examples below use JavaScript. They are deliberately small, with their limitations explained rather than hidden behind a "production-ready" label.
⏱️ 1. useDebouncedValue: wait until input settles
The problem
A user types:
r → re → rea → reac → react
If every change triggers expensive work, you may perform five operations when the user only wanted the final result. Debouncing waits for a quiet period.
Implementation
import { useEffect, useState } from "react";
export function useDebouncedValue(value, delay = 350) {
const [debouncedValue, setDebouncedValue] =
useState(value);
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeoutId);
};
}, [value, delay]);
return debouncedValue;
}
How it works
The hook keeps two values:
-
value: the latest input. -
debouncedValue: the last input that stayed unchanged long enough.
When the input changes, the effect starts a timer.
If the input changes again before the delay ends, cleanup cancels the previous timer. A new timer starts for the new value.
The same cleanup also cancels a pending timer when the component unmounts. Both value and delay are dependencies because changing either should restart the waiting period.
Usage
Keep the input responsive. Debounce the work that follows it.
import { useMemo, useState } from "react";
import { useDebouncedValue } from "./useDebouncedValue";
export function ProductSearch({ products }) {
const [search, setSearch] = useState("");
const debouncedSearch =
useDebouncedValue(search, 350);
const results = useMemo(() => {
const query =
debouncedSearch.trim().toLowerCase();
return products.filter((product) =>
product.name.toLowerCase().includes(query)
);
}, [products, debouncedSearch]);
return (
<section>
<label>
Search products
<input
value={search}
onChange={(event) =>
setSearch(event.target.value)
}
/>
</label>
<p>{results.length} products found</p>
<ul>
{results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</section>
);
}
For a small local list, immediate filtering may feel better. Debouncing becomes useful when the work is expensive or involves a request.
What this hook does not solve
Debouncing doesn't cancel a request that has already started.
If a request for "rea" is still running when a request for "react" begins, the older response could arrive last. For network searches, also handle cancellation or stale responses—often with an AbortController or a data-fetching library.
[!NOTE]
useDebouncedValuevs. React'suseDeferredValue
useDeferredValue(built into React 18+) addresses CPU-bound rendering responsiveness. It tells React to keep the input responsive while deferring an expensive list re-render in the background without any fixed delay.useDebouncedValueaddresses I/O, network requests, and timers. It enforces a fixed quiet period (e.g. 350ms) before triggering remote API requests, database queries, or server-side validations.
Good uses: search requests, delayed previews, validation after typing pauses.
Avoid: debouncing the input's own displayed value. That makes typing feel broken.
🌐 2. useOnlineStatus: subscribe to connection changes
The problem
Several components may need to show a connection warning. You could add online and offline listeners inside each component. A hook gives those components one consistent interface.
Here, the browser is the source of truth. useSyncExternalStore is designed for subscribing to state outside React. React's external-store reference
Implementation
import { useSyncExternalStore } from "react";
function subscribe(onStoreChange) {
window.addEventListener("online", onStoreChange);
window.addEventListener("offline", onStoreChange);
return () => {
window.removeEventListener(
"online",
onStoreChange
);
window.removeEventListener(
"offline",
onStoreChange
);
};
}
function getSnapshot() {
return typeof navigator !== "undefined" ? navigator.onLine : true;
}
function getServerSnapshot() {
return true;
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
}
How it works
There are three pieces:
-
subscribetells React when the browser's status changes. -
getSnapshotreads the current value. -
getServerSnapshotsupplies a value during server rendering and initial hydration.
The server cannot inspect the user's browser connection. Returning true is our chosen initial assumption, not a measurement.
The subscription functions live outside the hook because they don't depend on component props. Their identities remain stable between renders.
Usage
import { useOnlineStatus } from "./useOnlineStatus";
export function ConnectionNotice() {
const isOnline = useOnlineStatus();
if (isOnline) {
return null;
}
return (
<p role="status">
You appear to be offline. Recent changes may
not have synced yet.
</p>
);
}
The important limitation
navigator.onLine is a hint, not proof that your API is reachable. A device may have a network connection while your server is unavailable. VPNs and other network conditions can also affect the result. MDN's explanation of navigator.onLine
Use this hook for helpful UI messaging. Don't use it as the only reason to disable a submit button. Try the operation and handle its actual result.
Good uses: connection banners, offline indicators, explaining delayed synchronization.
Avoid: treating it as a backend health check.
📱 3. useMediaQuery: use browser preferences in component behavior
The problem
CSS handles responsive presentation well:
.cards {
display: grid;
grid-template-columns: 1fr;
}
@media (min-width: 900px) {
.cards {
grid-template-columns: repeat(3, 1fr);
}
}
You don't need a hook for that.
But sometimes a browser preference changes JavaScript behavior. For example, you may want to stop a canvas animation when the user prefers reduced motion.
matchMedia exposes a media query's current result and change events. MDN's matchMedia reference
Implementation
import {
useCallback,
useMemo,
useSyncExternalStore,
} from "react";
export function useMediaQuery(
query,
serverFallback = false
) {
const mediaQuery = useMemo(() => {
if (typeof window === "undefined") {
return null;
}
return window.matchMedia(query);
}, [query]);
const subscribe = useCallback(
(onStoreChange) => {
if (!mediaQuery) {
return () => {};
}
mediaQuery.addEventListener(
"change",
onStoreChange
);
return () => {
mediaQuery.removeEventListener(
"change",
onStoreChange
);
};
},
[mediaQuery]
);
const getSnapshot = useCallback(
() => mediaQuery?.matches ?? serverFallback,
[mediaQuery, serverFallback]
);
const getServerSnapshot = useCallback(
() => serverFallback,
[serverFallback]
);
return useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
}
How it works
The query creates a MediaQueryList. The hook subscribes to its change event and exposes its boolean matches value.
If the query changes, the hook creates a new subscription and removes the old one.
The server fallback matters because a server doesn't know the browser's viewport or preferences. Use the same fallback on the server and client for initial hydration.
Usage
import { useMediaQuery } from "./useMediaQuery";
export function Hero() {
const prefersReducedMotion = useMediaQuery(
"(prefers-reduced-motion: reduce)",
true
);
return (
<section>
<h1>Build something worth showing.</h1>
{prefersReducedMotion ? (
<StaticIllustration />
) : (
<AnimatedIllustration />
)}
</section>
);
}
StaticIllustration and AnimatedIllustration represent your application's illustration components.
Here, true is a conservative initial fallback: start with the static illustration until the browser's preference is available.
What to avoid
Don't replace every CSS media query with React state. Use CSS for spacing, columns, font sizes, and element positioning.
Use this hook when JavaScript behavior genuinely depends on the query.
Also remember that conditionally replacing components can discard their local state. Switching between a mobile and desktop form should not accidentally erase the user's input.
Good uses: reduced-motion behavior, enabling an interactive visualization, adapting controls.
Avoid: measuring viewport width in React just to change a margin.
💾 4. useLocalStorageState: remember a preference or draft
The problem
A user selects a preference or writes a draft. Refreshing the page loses it. A storage hook can coordinate React state with browser persistence.
But this hook needs more care than:
localStorage.setItem(key, JSON.stringify(value));
Reading can fail. Writing can fail. Stored JSON can be invalid. And an initial render must not overwrite an existing saved value before it has been read.
Implementation
This version keeps component-local state and persists changes after loading.
import {
useCallback,
useEffect,
useState,
} from "react";
export function useLocalStorageState(
key,
initialValue
) {
const [fallback] = useState(() => initialValue);
const [record, setRecord] = useState(() => ({
key,
value: fallback,
ready: false,
dirty: false,
}));
const [error, setError] = useState(null);
useEffect(() => {
let value = fallback;
let readError = null;
try {
const stored = window.localStorage.getItem(key);
if (stored !== null) {
value = JSON.parse(stored);
}
} catch (caught) {
readError = caught;
}
setError(readError);
setRecord({
key,
value,
ready: true,
dirty: false,
});
}, [key, fallback]);
useEffect(() => {
if (
record.key !== key ||
!record.ready ||
!record.dirty
) {
return;
}
try {
if (record.value === undefined) {
window.localStorage.removeItem(key);
} else {
const serialized = JSON.stringify(record.value);
if (serialized === undefined) {
throw new Error(
"The value cannot be stored as JSON."
);
}
window.localStorage.setItem(
key,
serialized
);
}
setError(null);
} catch (caught) {
setError(caught);
}
}, [key, record]);
const setValue = useCallback(
(nextValue) => {
setRecord((previous) => {
if (
previous.key !== key ||
!previous.ready
) {
return previous;
}
const value =
typeof nextValue === "function"
? nextValue(previous.value)
: nextValue;
return {
...previous,
value,
dirty: true,
};
});
},
[key]
);
const isReady =
record.key === key && record.ready;
return {
value:
record.key === key
? record.value
: fallback,
setValue,
isReady,
error,
};
}
Why the extra state?
ready means the initial read has finished. dirty means the user has changed the value.
Keeping those separate prevents the default value from immediately replacing a saved value.
It also means that invalid stored JSON is not silently overwritten during loading. The fallback is shown, an error is exposed, and a subsequent user edit can replace the invalid value.
The record includes its storage key so a key change cannot accidentally write the previous key's value into the new key.
Like useState, this hook treats initialValue as an initial fallback—not a prop that continuously resets the value.
Usage
import {
useLocalStorageState,
} from "./useLocalStorageState";
export function ArticleDraft() {
const {
value: draft,
setValue: setDraft,
isReady,
error,
} = useLocalStorageState(
"article-draft:v1",
""
);
return (
<section>
<label>
Your draft
<textarea
value={draft}
disabled={!isReady}
onChange={(event) =>
setDraft(event.target.value)
}
/>
</label>
{!isReady && <p>Loading saved draft…</p>}
{error && (
<p role="status">
Browser storage is unavailable or the
saved draft could not be read. You can
keep editing, but changes may not persist.
</p>
)}
</section>
);
}
If a write fails, React state still updates. The user can keep typing, but the interface warns that persistence may have failed.
Know the boundaries
localStorage stores strings, is synchronous, and may be unavailable because of browser policies. It is not a database or a guaranteed backup. MDN's localStorage reference
This implementation also:
- Accepts JSON-compatible values, not arbitrary JavaScript objects.
- Does not validate the shape of parsed data.
- Does not synchronize separate hook instances or browser tabs.
- Does not guarantee the latest edit survives an immediate unmount.
- Does not make sensitive data safe to store.
For object data, validate the parsed structure and plan migrations. A versioned key such as "preferences:v2" can help, but changing a key is not itself a migration strategy.
If you need cross-tab updates, subscribe to storage changes. Remember that the browser's storage event fires in other documents—not the window that performed the write. Same-page synchronization needs an additional mechanism. MDN's storage event reference
[!TIP]
Cross-Tab Synchronization Tip:
To sync state between open browser tabs in real-time, add an effect that listens towindow.addEventListener("storage", (e) => { if (e.key === key) { ... } }). When a user toggles dark mode or updates a preference in Tab A, Tab B updates automatically.
Good uses: lightweight preferences, dismissed notices, non-sensitive drafts.
Avoid: passwords, tokens, large frequently updated datasets, or business-critical persistence.
👁️ 5. useInView: observe visibility without scroll calculations
The problem
You want to start an animation or mount an expensive preview when a section becomes visible.
One approach is to listen to every scroll event and calculate element positions. Another is to let the browser observe the element.
IntersectionObserver reports changes in an element's intersection with a viewport or specified root. MDN's Intersection Observer guide
Implementation
This hook accepts configuration options including an optional triggerOnce flag:
import { useEffect, useState } from "react";
export function useInView({
root = null,
rootMargin = "0px",
threshold = 0.25,
triggerOnce = false,
} = {}) {
const [element, setElement] = useState(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
setIsInView(false);
if (!element) {
return;
}
if (
typeof IntersectionObserver === "undefined"
) {
setIsInView(true);
return;
}
let active = true;
const observer = new IntersectionObserver(
([entry]) => {
if (!active) {
return;
}
const inView =
entry.isIntersecting &&
entry.intersectionRatio >= threshold;
setIsInView(inView);
if (inView && triggerOnce) {
observer.disconnect();
}
},
{
root,
rootMargin,
threshold,
}
);
observer.observe(element);
return () => {
active = false;
observer.disconnect();
};
}, [element, root, rootMargin, threshold, triggerOnce]);
return {
ref: setElement,
isInView,
};
}
How it works
The returned callback ref (ref: setElement) tells the hook which element to observe. Using a callback ref is important: unlike a standard useRef(), whose .current assignment does not notify React or trigger effects, a callback ref guarantees that React runs your setup effect as soon as the DOM node attaches or detaches.
When the target element changes, the old observer disconnects and a new one starts.
The active flag prevents a late callback from an obsolete observer from updating the current state. If triggerOnce is set to true, the observer disconnects after its first intersection, keeping the element active (ideal for one-time enter animations).
For this hook, "in view" means:
The element is intersecting, and its visible ratio meets the configured threshold.
That explicit ratio check matters. isIntersecting alone does not mean the element has reached a threshold such as 25%.
Usage
import { useInView } from "./useInView";
export function DemoSection() {
const { ref, isInView } = useInView({
threshold: 0.25,
});
return (
<section
ref={ref}
style={{ minHeight: 360 }}
>
<h2>Try the interactive demo</h2>
{isInView ? (
<ExpensivePreview />
) : (
<p>The preview loads as you scroll here.</p>
)}
</section>
);
}
ExpensivePreview represents your application's interactive preview component.
The stable wrapper height matters. If mounting and unmounting the preview changes the section's geometry dramatically, visibility can oscillate.
This example unmounts the preview when it leaves view. If the preview has state that should survive, keep it mounted after the first visit or pause its work instead.
What to avoid
Don't hide essential content permanently if observation is unavailable. This implementation falls back to true.
For infinite scrolling, visibility is only one signal. You also need guards such as:
if (isInView && hasNextPage && !isFetching) {
// Request the next page.
}
Otherwise, repeated visibility updates can trigger duplicate requests.
For ordinary images, native lazy loading may be enough:
<img
src="/preview.webp"
alt="Application preview"
loading="lazy"
/>
Good uses: optional previews, scroll-triggered animation, pausing offscreen work.
Avoid: replacing simpler browser features without a reason.
Writing a useful hook is about its contract
These five hooks wrap different things:
| Hook | What it coordinates |
|---|---|
useDebouncedValue |
A value and a timer |
useOnlineStatus |
Browser connection events |
useMediaQuery |
A browser query subscription |
useLocalStorageState |
Local state and persistence |
useInView |
A DOM element and an observer |
Before extracting a hook, answer these questions:
- What does it own?
- What inputs can change?
- What does it return?
- What must be cleaned up?
- What happens if the browser feature fails?
- Are separate callers independent or synchronized?
Naming helps reveal the contract. useInView tells the caller what it provides. useMountEffect mostly tells the caller when something runs, while hiding what it does.
Prefer hooks organized around a behavior rather than wrappers that obscure effect dependencies.
A hook is not an excuse to add another effect
Some values should simply be calculated during rendering:
const fullName = `${firstName} ${lastName}`;
They don't need another state variable, effect, or custom hook.
Likewise, behavior caused directly by a user action often belongs in the event handler.
Effects are most useful when coordinating React with something outside it: a timer, event subscription, observer, or browser storage. React's guide to avoiding unnecessary effects
In frameworks with React Server Components, use these hooks within the appropriate client-component boundary. Browser APIs should not be assumed to exist on the server.
TypeScript definitions
For TypeScript projects, here are the ready-to-use type signatures for all five hooks:
View TypeScript signatures
// 1. useDebouncedValue
export function useDebouncedValue(value: T, delay?: number): T;
// 2. useOnlineStatus
export function useOnlineStatus(): boolean;
// 3. useMediaQuery
export function useMediaQuery(
query: string,
serverFallback?: boolean
): boolean;
// 4. useLocalStorageState
export interface LocalStorageStateReturn {
value: T;
setValue: (value: T | ((prev: T) => T)) => void;
isReady: boolean;
error: Error | null;
}
export function useLocalStorageState(
key: string,
initialValue: T
): LocalStorageStateReturn;
// 5. useInView
export interface UseInViewOptions {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number;
triggerOnce?: boolean;
}
export interface UseInViewReturn {
ref: (node: Element | null) => void;
isInView: boolean;
}
export function useInView(
options?: UseInViewOptions
): UseInViewReturn;
Before you reuse a hook, test its awkward cases
The happy path is usually easy. Cleanup and failure cases deserve attention.
For these examples, check:
- Debounce: rapid changes cancel older timers; unmounting cancels pending work.
- Online status: listeners are removed; request failures are handled independently.
- Media query: changing the query removes the previous subscription.
- Storage: existing values are preserved; invalid JSON and failed writes are surfaced.
- In view: changing the target disconnects the old observer; unavailable APIs have a safe fallback.
You don't need a hook for every repeated expression.
You need one when several components should rely on the same behavior—and you want that behavior's lifecycle, assumptions, and limitations maintained in one place.
What behavior have you copied between React components that would make a good custom hook?


Top comments (0)