Overview
Custom hooks let you extract component logic into reusable functions. They are the primary way to share stateful behavior in React without higher-order components or render props. This tutorial covers the rules, several real-world hooks, and testing.
Rules of Hooks
- Only call hooks at the top level of a component or another hook. Never inside loops, conditions, or nested functions.
- Only call hooks from React function components or custom hooks.
- A custom hook's name must start with
use. This lets the linter enforce the first two rules.
Your First Custom Hook
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
export default useLocalStorage;
Usage in a component:
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
Example: useFetch
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setData(await response.json());
} catch (err) {
if (err.name !== 'AbortError') setError(err);
} finally {
setLoading(false);
}
}
load();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
The AbortController cancels in-flight requests when the URL changes or the component unmounts, preventing state updates on unmounted components.
Example: useDebounce
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const { data } = useFetch(`/api/search?q=${debouncedQuery}`);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results items={data} />
</>
);
}
Example: useMediaQuery
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
);
useEffect(() => {
const media = window.matchMedia(query);
const listener = (event) => setMatches(event.matches);
media.addEventListener('change', listener);
setMatches(media.matches);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}
function Layout() {
const isDesktop = useMediaQuery('(min-width: 1024px)');
return isDesktop ? <DesktopNav /> : <MobileNav />;
}
Example: useInterval
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
The ref pattern keeps the interval stable while always invoking the latest callback. Passing null as the delay pauses the interval.
Composing Hooks
Custom hooks can call other custom hooks. This makes them composable building blocks.
function useUserProfile(userId) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
const isDesktop = useMediaQuery('(min-width: 1024px)');
return { user, loading, error, isDesktop };
}
When to Extract a Custom Hook
| Signal | Action |
|---|---|
Same useState + useEffect pattern in two or more components | Extract a hook |
| Component exceeds ~150 lines | Split logic into hooks |
| Complex effect with cleanup | Isolate in a named hook |
| Logic needs unit tests without rendering a full component | Extract and test with renderHook |
Testing Custom Hooks
import { renderHook, act, waitFor } from '@testing-library/react';
import useLocalStorage from './useLocalStorage';
test('persists value to localStorage', () => {
const { result } = renderHook(() => useLocalStorage('key', 'initial'));
expect(result.current[0]).toBe('initial');
act(() => result.current[1]('updated'));
expect(result.current[0]).toBe('updated');
expect(window.localStorage.getItem('key')).toBe('"updated"');
});
test('useFetch returns data', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 1 }) })
);
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({ id: 1 });
});
Naming and API Conventions
- Return an array when the order is obvious and stable, like
[value, setValue]. - Return an object when there are three or more values, so callers use named properties.
- Accept an options object for anything beyond one or two parameters.
- Document cleanup behavior: what happens on unmount, and whether the hook aborts or cancels work.
- Avoid returning a new object or function on every render without memoization if consumers put it in a dependency array.
Common Pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Conditional hook calls | Hook order changes between renders | Always call hooks unconditionally at the top |
| Missing cleanup | Memory leaks, state updates on unmounted components | Return a cleanup function from useEffect |
| Stale closures | Hook reads outdated state | Add to dependency array or use a ref |
| Returning a new function each render | Consumer effects re-run | Wrap in useCallback |
| Sharing state between hook instances | Unexpected coupling | Each hook call has its own state by design |
