Custom Hooks
11 of 11Battle-tested reusable hooks asked about in frontend interviews. Each one ships a live demo, the full TypeScript implementation, real usage, and the gotcha interviewers probe for. The source mirrors importable hooks under src/hooks/.
useToggleeasyBoolean state with a stable toggle + explicit on/off setters
Wraps a boolean useState and returns toggle / setOn / setOff callbacks that keep the same identity across renders (memoised with useCallback). Because the references never change, you can pass them to React.memo children without triggering pointless re-renders.
Live demo
Implementation
import { useCallback, useState } from 'react';
export default function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
const setOn = useCallback(() => setValue(true), []);
const setOff = useCallback(() => setValue(false), []);
return { value, toggle, setOn, setOff, setValue } as const;
}Usage
const { value: isOpen, toggle, setOff } = useToggle();
<button onClick={toggle}>Toggle</button>
{isOpen && <Modal onClose={setOff} />}When to use it
- Modal / drawer / accordion open state
- Show-more / show-less sections
- Boolean feature flags in local UI state
Warning
Watch out
useCopyToClipboardeasyCopy text and get a transient "copied" flag
Writes text via the async Clipboard API and flips a `copied` flag that auto-resets after a delay. Returns false when the API is unavailable or the write is rejected (e.g. insecure context).
Live demo
npm install react-virtuosoImplementation
import { useCallback, useState } from 'react';
export default function useCopyToClipboard(resetDelay = 1500) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
async (text: string): Promise<boolean> => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return false;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), resetDelay);
return true;
} catch {
setCopied(false);
return false;
}
},
[resetDelay]
);
return { copied, copy } as const;
}Usage
const { copied, copy } = useCopyToClipboard();
<button onClick={() => copy('npm i')}>
{copied ? 'Copied!' : 'Copy'}
</button>When to use it
- Copy code snippets / install commands
- "Copy link" share buttons
- Copy API keys or generated tokens
Warning
Watch out
useLocalStorageeasyuseState that persists to localStorage and syncs across tabs
Behaves like useState but reads its initial value from localStorage and writes every update back. A storage event listener keeps the value in sync when the same key changes in another tab.
Live demo
Reload the page or open another tab, the value sticks. Stored under custom-hooks-demo-name.
Implementation
import { useState, useEffect } from 'react';
export default function useLocalStorage<ValueType>(key: string, defaultValue: ValueType) {
const [value, setValue] = useState(() => {
const stored = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
return stored === null ? defaultValue : JSON.parse(stored);
});
useEffect(() => {
const listener = (e: StorageEvent) => {
if (e.storageArea === localStorage && e.key === key) {
setValue(e.newValue ? JSON.parse(e.newValue) : e.newValue);
}
};
window.addEventListener('storage', listener);
return () => window.removeEventListener('storage', listener);
}, [key, defaultValue]);
const setValueInLocalStorage = (newValue: ValueType) => {
setValue((current: any) => {
const result = typeof newValue === 'function' ? newValue(current) : newValue;
localStorage.setItem(key, JSON.stringify(result));
return result;
});
};
return [value, setValueInLocalStorage];
}Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
{theme}
</button>When to use it
- Theme / language preferences
- Persisting form drafts
- Remembering dismissed banners or onboarding state
Warning
Watch out
useDebouncemediumTrailing-debounced copy of a value
Returns a value that only updates after `delay`ms of silence. Each change restarts the timer, so a burst of updates (fast typing) collapses into one final update after the user pauses , the canonical "search as you type" optimisation.
Live demo
Live: ,
Debounced (500ms): ,
Implementation
import { useEffect, useState } from 'react';
export default function useDebounce<T>(value: T, delay = 500): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}Usage
const [query, setQuery] = useState('');
const debounced = useDebounce(query, 400);
useEffect(() => { if (debounced) search(debounced); }, [debounced]);When to use it
- Search-as-you-type API calls
- Validating an input only after the user pauses
- Autosaving a draft after editing stops
Warning
Watch out
useThrottlemediumValue that updates at most once per interval
Returns a copy of the value that changes at most once every `interval`ms. Unlike debounce (which waits for a pause), throttle emits on a fixed cadence even while the source keeps changing , ideal for scroll, mousemove, and resize.
Live demo
Live clicks: 0
Throttled (≤1/sec): 0
Implementation
import { useEffect, useRef, useState } from 'react';
export default function useThrottle<T>(value: T, interval = 500): T {
const [throttled, setThrottled] = useState(value);
const lastRun = useRef(Date.now());
useEffect(() => {
const elapsed = Date.now() - lastRun.current;
if (elapsed >= interval) {
lastRun.current = Date.now();
setThrottled(value);
} else {
const id = setTimeout(() => {
lastRun.current = Date.now();
setThrottled(value);
}, interval - elapsed);
return () => clearTimeout(id);
}
}, [value, interval]);
return throttled;
}Usage
const [scrollY, setScrollY] = useState(0); const throttledY = useThrottle(scrollY, 200); // throttledY updates ~5x/sec no matter how fast you scroll
When to use it
- Scroll-position-driven UI (progress bars, sticky headers)
- Mousemove / drag tracking
- Rate-limiting high-frequency analytics events
Warning
Watch out
usePreviousmediumThe value from the previous render
Answers "what was this value last render?" using a ref written inside an effect. The trick: effects run AFTER render, so while rendering, the ref still holds the previous value , no extra state, no extra re-renders.
Live demo
Implementation
import { useEffect, useRef } from 'react';
export default function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}Usage
const prevCount = usePrevious(count);
<p>Now: {count} · was: {prevCount ?? '—'}</p>When to use it
- Detecting which prop changed in an effect
- Animating based on the delta between renders
- Comparing old vs new value before running side effects
Warning
Watch out
useWindowSizemediumReactive viewport width & height
Tracks window.innerWidth / innerHeight and updates on resize. Seeds to 0×0 on the server and syncs once on mount, so the first client render matches the server output and avoids a hydration mismatch.
Live demo
0 × 0px
Resize the browser window to watch it update.
Implementation
import { useEffect, useState } from 'react';
interface WindowSize { width: number; height: number; }
export default function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>({ width: 0, height: 0 });
useEffect(() => {
const onResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
onResize(); // sync once on mount
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}Usage
const { width } = useWindowSize();
const isMobile = width > 0 && width < 768;When to use it
- JS-driven responsive logic beyond CSS media queries
- Conditionally rendering desktop vs mobile components
- Sizing canvases / charts to the viewport
Warning
Watch out
useClickOutsidemediumRun a handler when the user clicks outside an element
Returns a ref to attach to an element; the handler fires whenever a mousedown / touchstart lands outside it. The latest handler is stored in a ref so the document listener is attached only once.
Live demo
Implementation
import { useEffect, useRef } from 'react';
export default function useClickOutside<T extends HTMLElement = HTMLElement>(
handler: (event: MouseEvent | TouchEvent) => void
) {
const ref = useRef<T>(null);
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref.current;
if (!el || el.contains(event.target as Node)) return;
handlerRef.current(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, []);
return ref;
}Usage
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
{open && <div ref={ref}>Dropdown…</div>}When to use it
- Closing dropdowns / popovers / menus
- Dismissing modals on backdrop click
- Exiting an inline edit field
Warning
Watch out
useOnlineStatusmediumTrack network connectivity (offline detection)
Reports whether the browser is online. Seeds from navigator.onLine (guarded for SSR) and stays in sync via the window online / offline events. Drive offline banners or pause network work with it.
Live demo
Toggle “Offline” in DevTools → Network (or turn off Wi-Fi) to see it flip.
Implementation
import { useEffect, useState } from 'react';
export default function useOnlineStatus(): boolean {
const [online, setOnline] = useState(() =>
typeof navigator !== 'undefined' ? navigator.onLine : true
);
useEffect(() => {
const goOnline = () => setOnline(true);
const goOffline = () => setOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return online;
}Usage
const isOnline = useOnlineStatus();
{!isOnline && <Banner>You are offline, changes will sync later.</Banner>}When to use it
- Offline banners / toasts
- Pausing polling or queuing mutations while offline
- Disabling submit buttons that need the network
Warning
Watch out
useIntersectionObserveradvancedKnow when an element enters the viewport
Attaches an IntersectionObserver to the returned ref and reports whether the element is intersecting. With freezeOnceVisible it disconnects after the first reveal , perfect for lazy-loading and one-shot animations.
Live demo
Scroll me into / out of view
Implementation
import { useEffect, useRef, useState } from 'react';
export default function useIntersectionObserver<T extends HTMLElement = HTMLElement>(
options: IntersectionObserverInit & { freezeOnceVisible?: boolean } = {}
) {
const { freezeOnceVisible = false, ...observerInit } = options;
const ref = useRef<T>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
const frozen = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined' || frozen.current) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && freezeOnceVisible) {
frozen.current = true;
observer.disconnect();
}
}, observerInit);
observer.observe(el);
return () => observer.disconnect();
}, [freezeOnceVisible, observerInit.root, observerInit.rootMargin, observerInit.threshold]);
return { ref, isIntersecting } as const;
}Usage
const { ref, isIntersecting } = useIntersectionObserver({ freezeOnceVisible: true });
<img ref={ref} src={isIntersecting ? realSrc : placeholder} />When to use it
- Lazy-loading images / components on scroll
- Reveal-on-scroll animations
- Infinite scroll (observe a sentinel at the list end)
- Impression tracking for analytics
Warning
Watch out
useFetchadvancedDeclarative data fetching with loading / error / abort
Fetches a URL and returns { data, loading, error }. It re-runs when the URL changes and aborts the in-flight request on cleanup, so a fast re-render or unmount can never set state from a stale response (avoiding the classic race + memory-leak warning).
Live demo
Loading…
Implementation
import { useEffect, useState } from 'react';
interface FetchState<T> { data: T | null; loading: boolean; error: Error | null; }
export default function useFetch<T = unknown>(url: string, options?: RequestInit): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { ...options, signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ data, loading: false, error: null }))
.catch((error: Error) => {
if (error.name !== 'AbortError') setState({ data: null, loading: false, error });
});
return () => controller.abort();
}, [url]);
return state;
}Usage
const { data, loading, error } = useFetch<User>('/api/me');
if (loading) return <Spinner />;
if (error) return <Error msg={error.message} />;
return <Profile user={data!} />;When to use it
- Simple GET requests tied to a component
- Re-fetching when an id / query param changes
- Learning the fetch + AbortController pattern before reaching for React Query