Skip to content

Custom Hooks

Custom hooks are functions that start with use and can call other hooks. They let you extract stateful logic out of components so it can be reused across multiple components.

  • Reuse stateful logic without restructuring component hierarchy
  • Separate concerns โ€” UI components stay focused on rendering
  • Test logic independently from components
// A hook that fetches data
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(data => {
if (!cancelled) setData(data);
})
.catch(err => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user?.name}</div>;
}

Persist state across page reloads:

function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const next = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prev)
: newValue;
localStorage.setItem(key, JSON.stringify(next));
return next;
});
}, [key]);
return [value, setStoredValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

Delay state updates โ€” useful for search inputs:

function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Respond to viewport changes:

function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const media = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery('(max-width: 768px)');

Close dropdowns and modals when clicking outside:

function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Usage
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen(o => !o)}>Toggle</button>
{open && <div className="dropdown-menu">...</div>}
</div>
);
}

Access the previous value of a state or prop:

function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// Usage
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return <div>Current: {count}, Previous: {prevCount}</div>;
}

Custom hooks follow the same rules as built-in hooks:

  • Only call hooks at the top level (not in loops or conditions)
  • Only call hooks from React functions or other hooks
  • Name must start with use โ€” this is what lets linters enforce the rules above