React Custom Hooks: Build Reusable Logic (2026)
Custom hooks are the single best tool for code reuse in React. They let you extract stateful logic from components into standalone functions that any component can consume — without changing your component hierarchy. This guide covers ten production-ready hooks with full TypeScript types, cleanup patterns, and testing examples.
Table of Contents
Rules of Hooks Recap
Custom hooks must follow the same rules as built-in hooks or React will throw runtime errors:
- Only call hooks at the top level — not inside loops, conditions, or nested functions.
- Only call hooks from React functions — function components or other custom hooks, never plain JS functions.
- Name custom hooks with the
useprefix — this is how the React linter (eslint-plugin-react-hooks) identifies them and enforces the rules above.
use prefix. The prefix is a signal to linters and readers that the function participates in the hook rules contract.useFetch with AbortController
The most common custom hook. Wraps fetch with loading/error state and — critically — cancels in-flight requests when the component unmounts or the URL changes. Without the AbortController cleanup you get state updates on unmounted components and memory leaks.
// hooks/useFetch.ts
import { useState, useEffect, useCallback } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
export function useFetch<T = unknown>(url: string, options?: RequestInit): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [trigger, setTrigger] = useState(0);
const refetch = useCallback(() => setTrigger((n) => n + 1), []);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { ...options, signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<T>;
})
.then((json) => {
setData(json);
setLoading(false);
})
.catch((err: Error) => {
if (err.name === 'AbortError') return; // ignore cancellation
setError(err);
setLoading(false);
});
// Cleanup: cancel the request when url changes or component unmounts
return () => controller.abort();
}, [url, trigger]); // eslint-disable-line react-hooks/exhaustive-deps
return { data, loading, error, refetch };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, refetch } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message} <button onClick={refetch}>Retry</button></p>;
return <p>{user?.name}</p>;
}
useLocalStorage
Keeps a piece of state in sync with localStorage, handling JSON serialization, deserialization, and SSR safety (localStorage doesn't exist on the server).
// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const readValue = useCallback((): 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;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
const newValue = value instanceof Function ? value(storedValue) : value;
setStoredValue(newValue);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(newValue));
// Notify other tabs
window.dispatchEvent(new StorageEvent('storage', { key, newValue: JSON.stringify(newValue) }));
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
setStoredValue(initialValue);
if (typeof window !== 'undefined') window.localStorage.removeItem(key);
}, [key, initialValue]);
// Sync with other tabs
useEffect(() => {
const handler = (e: StorageEvent) => {
if (e.key === key) setStoredValue(e.newValue ? JSON.parse(e.newValue) : initialValue);
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'dark');
return (
<button onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}>
Current: {theme}
</button>
);
}
useDebounce and useThrottle
useDebounce delays updating a value until the user has stopped changing it for a specified duration — perfect for search inputs that fire API calls. useThrottle limits updates to at most once per interval — useful for scroll handlers.
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// hooks/useThrottle.ts
import { useState, useEffect, useRef } from 'react';
export function useThrottle<T>(value: T, interval = 200): T {
const [throttledValue, setThrottledValue] = useState<T>(value);
const lastUpdated = useRef<number | null>(null);
useEffect(() => {
const now = Date.now();
if (lastUpdated.current === null || now - lastUpdated.current >= interval) {
lastUpdated.current = now;
setThrottledValue(value);
} else {
const timer = setTimeout(() => {
lastUpdated.current = Date.now();
setThrottledValue(value);
}, interval - (now - (lastUpdated.current ?? now)));
return () => clearTimeout(timer);
}
}, [value, interval]);
return throttledValue;
}
// Usage — search input with debounce
function SearchBox() {
const [input, setInput] = useState('');
const debouncedQuery = useDebounce(input, 400);
const { data: results } = useFetch<SearchResult[]>(
debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
);
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Search..." />
{results?.map((r) => <p key={r.id}>{r.title}</p>)}
</div>
);
}
useClickOutside
Fires a callback when the user clicks anywhere outside a referenced element. Used for closing dropdowns, modals, and tooltips.
// hooks/useClickOutside.ts
import { useEffect, useRef, RefObject } from 'react';
export function useClickOutside<T extends HTMLElement>(
callback: () => void
): RefObject<T> {
const ref = useRef<T>(null);
useEffect(() => {
const handler = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('touchstart', handler);
};
}, [callback]);
return ref;
}
// Usage
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
return (
<div ref={ref} style={{ position: 'relative' }}>
<button onClick={() => setOpen((o) => !o)}>Menu</button>
{open && (
<ul style={{ position: 'absolute', top: '100%', left: 0 }}>
<li>Option 1</li>
<li>Option 2</li>
</ul>
)}
</div>
);
}
useWindowSize
Returns the current window dimensions and updates reactively on resize. Includes cleanup to remove the event listener on unmount.
// hooks/useWindowSize.ts
import { useState, useEffect } from 'react';
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
});
useEffect(() => {
const handler = () =>
setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// Usage
function ResponsiveNav() {
const { width } = useWindowSize();
return width < 768 ? <MobileNav /> : <DesktopNav />;
}
useIntersectionObserver
Tracks whether an element is visible in the viewport using the Intersection Observer API. Powers lazy image loading, infinite scroll triggers, and animation-on-enter effects.
// hooks/useIntersectionObserver.ts
import { useState, useEffect, useRef, RefObject } from 'react';
interface Options extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
export function useIntersectionObserver<T extends HTMLElement>(
options: Options = {}
): [RefObject<T>, boolean] {
const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = options;
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
const visible = entry.isIntersecting;
setIsVisible(visible);
if (freezeOnceVisible && visible) observer.disconnect();
},
{ threshold, root, rootMargin }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold, root, rootMargin, freezeOnceVisible]);
return [ref, isVisible];
}
// Usage — lazy load image
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [ref, isVisible] = useIntersectionObserver<HTMLDivElement>({
threshold: 0.1,
freezeOnceVisible: true,
});
return (
<div ref={ref} style={{ minHeight: 200 }}>
{isVisible ? (
<img src={src} alt={alt} loading="lazy" />
) : (
<div className="placeholder" />
)}
</div>
);
}
// Usage — infinite scroll
function InfiniteList() {
const [items, setItems] = useState<string[]>([]);
const [sentinelRef, isVisible] = useIntersectionObserver<HTMLDivElement>({ threshold: 1 });
useEffect(() => {
if (isVisible) {
// load next page
setItems((prev) => [...prev, ...Array.from({ length: 10 }, (_, i) => `Item ${prev.length + i + 1}`)]);
}
}, [isVisible]);
return (
<div>
{items.map((item) => <div key={item}>{item}</div>)}
<div ref={sentinelRef} />
</div>
);
}
usePrevious
Stores the previous render's value using a ref. Useful for animating from old state to new state, or detecting which direction a list changed.
// hooks/usePrevious.ts
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]); // runs after render — ref.current still holds the OLD value during render
return ref.current;
}
// Usage — show change direction
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>
{count} {prevCount !== undefined && `(was ${prevCount})`}
</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
<button onClick={() => setCount((c) => c - 1)}>-</button>
</div>
);
}
Testing Custom Hooks with renderHook
renderHook from @testing-library/react lets you test hooks in isolation without wrapping them in a real component. Pair it with act for state updates and waitFor for async operations.
// hooks/__tests__/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '../useDebounce';
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500));
expect(result.current).toBe('hello');
});
it('does not update before the delay has elapsed', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'hello' } }
);
rerender({ value: 'world' });
act(() => { jest.advanceTimersByTime(300); });
expect(result.current).toBe('hello');
});
it('updates after the delay has elapsed', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'hello' } }
);
rerender({ value: 'world' });
act(() => { jest.advanceTimersByTime(500); });
expect(result.current).toBe('world');
});
});
// hooks/__tests__/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '../useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => localStorage.clear());
it('returns the initial value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
it('persists a new value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
act(() => { result.current[1]('updated'); });
expect(result.current[0]).toBe('updated');
expect(JSON.parse(localStorage.getItem('key')!)).toBe('updated');
});
});
beforeEach block or in your Jest setup file. For IntersectionObserver, create a mock class that stores callbacks and lets you trigger them manually.FAQ
Can a custom hook return JSX?
Technically yes, but you should avoid it. A hook that returns JSX is doing two jobs — logic and rendering. Keep hooks as pure logic extractors and return data/handlers. If you need to share UI + behavior, use a component instead.
When should I extract logic into a custom hook vs a utility function?
Extract to a custom hook when the logic uses React primitives (useState, useEffect, useRef, useCallback, useMemo, other hooks). If the logic is pure computation with no side effects and no state, a regular utility function is simpler and easier to test.
Why does my useEffect inside a custom hook run more times than expected?
Usually the dependency array contains an object or function created inside the component that gets a new reference on every render. Stabilize it with useMemo or useCallback at the call site, or restructure the hook to derive the value internally.
How do I share state between multiple instances of the same custom hook?
By default, each hook call gets its own isolated state — like calling useState twice. To share state across hook instances, lift it out of the hook into a Zustand store, React context, or a module-level variable (for simple non-reactive sharing).
Should I memoize the return value of a custom hook?
Only memoize when you have a measured performance problem. Premature memoization adds complexity and can introduce stale closure bugs. The rule: if the hook returns an object or array and that return value is used as a dependency in another hook downstream, wrapping with useMemo prevents infinite loops — but fix the architecture first if possible.