Custom hooks extract reusable logic from components. Here is a collection of hooks for common patterns.
useLocalStorage
Persist state to localStorage with type safety.
"use client";
import { useCallback, useEffect, useState } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
// Storage full or unavailable
}
}, [key, storedValue]);
// Listen for changes from other tabs
useEffect(() => {
function handleStorage(e: StorageEvent) {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch {
// Invalid JSON
}
}
}
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [key]);
const remove = useCallback(() => {
setStoredValue(initialValue);
window.localStorage.removeItem(key);
}, [key, initialValue]);
return [storedValue, setStoredValue, remove];
}
Usage:
const [theme, setTheme] = useLocalStorage("theme", "light");
const [cart, setCart, clearCart] = useLocalStorage<string[]>("cart", []);
useMediaQuery
Responsive logic in your components.
"use client";
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
function handleChange(e: MediaQueryListEvent) {
setMatches(e.matches);
}
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [query]);
return matches;
}
Usage:
const isMobile = useMediaQuery("(max-width: 768px)");
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const isDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
useClickOutside
Close dropdowns, modals, and popovers.
"use client";
import { useEffect, useRef } from "react";
export function useClickOutside<T extends HTMLElement>(
callback: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [callback]);
return ref;
}
Usage:
const dropdownRef = useClickOutside<HTMLDivElement>(() => setOpen(false));
return <div ref={dropdownRef}>{/* dropdown content */}</div>;
useIntersectionObserver
Lazy loading, infinite scroll, and scroll-triggered animations.
"use client";
import { useEffect, useRef, useState } from "react";
interface UseIntersectionOptions {
threshold?: number;
rootMargin?: string;
triggerOnce?: boolean;
}
export function useIntersectionObserver<T extends HTMLElement>(
options: UseIntersectionOptions = {}
) {
const { threshold = 0, rootMargin = "0px", triggerOnce = false } = options;
const ref = useRef<T>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry) {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && triggerOnce) {
observer.unobserve(element);
}
}
},
{ threshold, rootMargin }
);
observer.observe(element);
return () => observer.disconnect();
}, [threshold, rootMargin, triggerOnce]);
return { ref, isIntersecting };
}
Usage:
const { ref, isIntersecting } = useIntersectionObserver<HTMLDivElement>({
threshold: 0.5,
triggerOnce: true,
});
return (
<div ref={ref} className={isIntersecting ? "animate-in" : "opacity-0"}>
Content appears when scrolled into view
</div>
);
useCopyToClipboard
"use client";
import { useCallback, useState } from "react";
export function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false);
const copy = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), timeout);
return true;
} catch {
setCopied(false);
return false;
}
},
[timeout]
);
return { copied, copy };
}
useWindowSize
"use client";
import { useEffect, useState } from "react";
export function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
handleResize(); // Set initial size
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
useCountdown
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export function useCountdown(initialSeconds: number) {
const [seconds, setSeconds] = useState(initialSeconds);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const start = useCallback(() => setIsRunning(true), []);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(() => {
setIsRunning(false);
setSeconds(initialSeconds);
}, [initialSeconds]);
useEffect(() => {
if (!isRunning || seconds <= 0) {
if (seconds <= 0) setIsRunning(false);
return;
}
intervalRef.current = setInterval(() => {
setSeconds((prev) => prev - 1);
}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, seconds]);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const formatted = `${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
return { seconds, formatted, isRunning, isFinished: seconds <= 0, start, pause, reset };
}
usePrevious
Track the previous value of a variable.
"use client";
import { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
useToggle
"use client";
import { useCallback, useState } from "react";
export function useToggle(
initialValue = false
): [boolean, () => void, (value: boolean) => void] {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((prev) => !prev), []);
return [value, toggle, setValue];
}
useEventListener
Type-safe event listener with automatic cleanup.
"use client";
import { useEffect, useRef } from "react";
export function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: HTMLElement | null
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const target = element ?? window;
function listener(event: Event) {
handlerRef.current(event as WindowEventMap[K]);
}
target.addEventListener(eventName, listener);
return () => target.removeEventListener(eventName, listener);
}, [eventName, element]);
}
Usage:
useEventListener("keydown", (e) => {
if (e.key === "Escape") setOpen(false);
});
Need Polished React Components?
We build production-ready component libraries with custom hooks, accessibility, and performance optimization. Contact us to build your component library.