Why Custom Hooks?
React's built-in hooks (useState, useEffect, useRef) are primitives. They handle individual concerns โ a piece of state, a side effect, a reference. But real applications repeat patterns: fetching data, syncing with localStorage, debouncing inputs, validating forms. Custom hooks let you extract that repeated logic into reusable functions.
A custom hook is any JavaScript function whose name starts with use and that calls other hooks internally. That naming convention is not optional โ React's linter uses it to enforce the Rules of Hooks.
+------------------+ +-------------------+
| Component A | | Component B |
| uses useFetch() | | uses useFetch() |
+--------+---------+ +---------+---------+
| |
+----------+ +-----------+
| |
+-----+----+-----+
| useFetch() |
| custom hook |
+----------------+
| - useState |
| - useEffect |
| - fetch logic |
+----------------+
Each component that calls useFetch gets its own independent copy of the state. Custom hooks share logic, not state.
The Rules
Before writing custom hooks, internalize the rules that govern all hooks:
- Only call hooks at the top level โ never inside loops, conditions, or nested functions
- Only call hooks from React functions โ function components or other custom hooks
- Name must start with
useโ this activates linting rules
// WRONG: conditional hook call
function SearchResults({ query }) {
if (query.length > 0) {
const data = useFetch(`/api/search?q=${query}`); // breaks Rules of Hooks
}
return <div>...</div>;
}
// RIGHT: always call the hook, handle the condition inside
function SearchResults({ query }) {
const data = useFetch(query.length > 0 ? `/api/search?q=${query}` : null);
return <div>...</div>;
}
React relies on call order to associate hook state with the correct hook call. Conditional calls break that order between renders.
ESLint Configuration for Hooks
The eslint-plugin-react-hooks package enforces both rules automatically. Install and configure it:
npm install eslint-plugin-react-hooks --save-dev
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
The rules-of-hooks rule catches conditional or nested hook calls. The exhaustive-deps rule catches missing dependencies in useEffect, useMemo, and useCallback โ critical for custom hooks that wrap these primitives.
If you use Next.js or Create React App, these rules are already included. Do not disable them.
Building useFetch
Data fetching is the most common repeated pattern. Let's build a useFetch hook from simple to production-ready.
Version 1: Basic Implementation
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
setData(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
fetch(url)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(json => {
setData(json);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [url]);
return { data, loading, error };
}
Usage in a component:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{user.name}</h1>;
}
This works, but has a bug: if userId changes rapidly, earlier fetch responses might arrive after later ones, showing stale data.
Version 2: With Abort Controller
import { useState, useEffect } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
setData(null);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { ...options, signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(json => {
setData(json);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
The AbortController cancels in-flight requests when the URL changes or the component unmounts. The cleanup function in useEffect ensures this happens automatically.
Version 3: With Refetch and Caching
import { useState, useEffect, useCallback, useRef } from 'react';
const cache = new Map();
function useFetch(url, options = {}) {
const { cacheTime = 0, enabled = true } = options;
const [data, setData] = useState(() => cache.get(url) ?? null);
const [loading, setLoading] = useState(!cache.has(url));
const [error, setError] = useState(null);
const controllerRef = useRef(null);
const fetchData = useCallback(async () => {
if (!url || !enabled) return;
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
if (cacheTime > 0) {
cache.set(url, json);
setTimeout(() => cache.delete(url), cacheTime);
}
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, [url, enabled, cacheTime]);
useEffect(() => {
fetchData();
return () => controllerRef.current?.abort();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
Now components can trigger a manual refetch:
function UserList() {
const { data: users, loading, error, refetch } = useFetch('/api/users', {
cacheTime: 60000, // cache for 1 minute
});
return (
<div>
<button onClick={refetch}>Refresh</button>
{loading && <p>Loading...</p>}
{error && <p>Error: {error}</p>}
{users?.map(user => <p key={user.id}>{user.name}</p>)}
</div>
);
}
Building useLocalStorage
Syncing state with localStorage is another constant pattern. The tricky parts: reading on mount (SSR safety), serialization, and cross-tab sync.
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
setStoredValue(prev => {
const valueToStore = value instanceof Function ? value(prev) : value;
try {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.warn(`Failed to save to localStorage key "${key}":`, err);
}
return valueToStore;
});
}, [key]);
const removeValue = useCallback(() => {
setStoredValue(initialValue);
try {
window.localStorage.removeItem(key);
} catch (err) {
console.warn(`Failed to remove localStorage key "${key}":`, err);
}
}, [key, initialValue]);
// Sync across tabs
useEffect(() => {
function handleStorageChange(e) {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch {
setStoredValue(initialValue);
}
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
Usage:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
function Settings() {
const [prefs, setPrefs, clearPrefs] = useLocalStorage('settings', {
notifications: true,
language: 'en',
});
return (
<div>
<label>
<input
type="checkbox"
checked={prefs.notifications}
onChange={e =>
setPrefs(prev => ({ ...prev, notifications: e.target.checked }))
}
/>
Notifications
</label>
<button onClick={clearPrefs}>Reset to defaults</button>
</div>
);
}
Key design decisions:
- Lazy initializer in
useStateensureslocalStorageis read only once - SSR guard (
typeof window === 'undefined') prevents crashes in Next.js - Functional updates (
value instanceof Function) mirroruseStateAPI storageevent listener syncs state when another tab changes the same keyremoveValueprovides a clean way to reset
Building useForm
Form handling involves multiple state values, validation, and submission logic. A custom hook consolidates all of it.
import { useState, useCallback } from 'react';
function useForm({ initialValues, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.target;
setValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error on change
setErrors(prev => {
const next = { ...prev };
delete next[name];
return next;
});
}, []);
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
// Validate single field on blur
if (validate) {
const fieldErrors = validate({ ...values });
if (fieldErrors[name]) {
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
}
}
}, [validate, values]);
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
setIsSubmitting(true);
// Mark all fields as touched
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }), {}
);
setTouched(allTouched);
// Run validation
const validationErrors = validate ? validate(values) : {};
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
try {
await onSubmit(values);
} catch (err) {
setErrors(prev => ({ ...prev, form: err.message }));
}
}
setIsSubmitting(false);
}, [values, validate, onSubmit]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
const getFieldProps = useCallback((name) => ({
name,
value: values[name] ?? '',
onChange: handleChange,
onBlur: handleBlur,
}), [values, handleChange, handleBlur]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
getFieldProps,
};
}
Usage:
function SignupForm() {
const form = useForm({
initialValues: { email: '', password: '', agree: false },
validate(values) {
const errors = {};
if (!values.email) errors.email = 'Email required';
else if (!/\S+@\S+\.\S+/.test(values.email)) errors.email = 'Invalid email';
if (!values.password) errors.password = 'Password required';
else if (values.password.length < 8) errors.password = 'Min 8 characters';
if (!values.agree) errors.agree = 'Must accept terms';
return errors;
},
async onSubmit(values) {
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error('Signup failed');
},
});
return (
<form onSubmit={form.handleSubmit}>
<div>
<input type="email" placeholder="Email" {...form.getFieldProps('email')} />
{form.touched.email && form.errors.email && (
<span className="error">{form.errors.email}</span>
)}
</div>
<div>
<input type="password" placeholder="Password" {...form.getFieldProps('password')} />
{form.touched.password && form.errors.password && (
<span className="error">{form.errors.password}</span>
)}
</div>
<div>
<label>
<input type="checkbox" name="agree" checked={form.values.agree} onChange={form.handleChange} />
I agree to the terms
</label>
{form.touched.agree && form.errors.agree && (
<span className="error">{form.errors.agree}</span>
)}
</div>
{form.errors.form && <p className="error">{form.errors.form}</p>}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}
The getFieldProps helper saves boilerplate by returning name, value, onChange, and onBlur as a spreadable object.
Building useDebounce
Debouncing delays execution until input stops changing. Essential for search fields, auto-save, and API calls that should not fire on every keystroke.
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
This is the value-based approach: you pass a raw value and get back the debounced version. Every time value changes, the timer resets. After delay milliseconds of no changes, the debounced value updates.
User types: H -> He -> Hel -> Hell -> Hello
| | | | |
Time: 0 50ms 100ms 200ms 400ms
|
Debounced output (300ms delay): "Hello" fires at 700ms
Usage:
function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const { data: results } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{results?.map(item => <p key={item.id}>{item.title}</p>)}
</div>
);
}
Callback-based Debounce
Sometimes you need to debounce a function call rather than a value:
import { useCallback, useRef, useEffect } from 'react';
function useDebouncedCallback(callback, delay = 300) {
const callbackRef = useRef(callback);
const timerRef = useRef(null);
// Keep callback ref fresh without resetting the timer
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const debouncedFn = useCallback((...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
}, [delay]);
// Cleanup on unmount
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return debouncedFn;
}
Usage:
function AutoSaveEditor() {
const [content, setContent] = useState('');
const save = useDebouncedCallback(async (text) => {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ content: text }),
});
}, 1000);
return (
<textarea
value={content}
onChange={e => {
setContent(e.target.value);
save(e.target.value);
}}
/>
);
}
Building useThrottle
Throttling ensures a function executes at most once per interval. Useful for scroll handlers, resize listeners, and rate-limited APIs.
Throttle vs Debounce:
Debounce: waits until input STOPS, then fires once
Events: * * * * * * * * ---- (pause) ----> FIRE
Throttle: fires at regular intervals DURING input
Events: * * * * * * * * * * * * * *
Fires: ^ ^ ^ (every N ms)
import { useState, useEffect, useRef } from 'react';
function useThrottle(value, interval = 300) {
const [throttledValue, setThrottledValue] = useState(value);
const lastUpdated = useRef(Date.now());
useEffect(() => {
const now = Date.now();
const elapsed = now - lastUpdated.current;
if (elapsed >= interval) {
setThrottledValue(value);
lastUpdated.current = now;
} else {
const timer = setTimeout(() => {
setThrottledValue(value);
lastUpdated.current = Date.now();
}, interval - elapsed);
return () => clearTimeout(timer);
}
}, [value, interval]);
return throttledValue;
}
Usage with a scroll position tracker:
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const throttledScrollY = useThrottle(scrollY, 100);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <div className="scroll-indicator">Scroll: {throttledScrollY}px</div>;
}
Callback-based Throttle
import { useCallback, useRef, useEffect } from 'react';
function useThrottledCallback(callback, interval = 300) {
const callbackRef = useRef(callback);
const lastRan = useRef(0);
const timerRef = useRef(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const throttledFn = useCallback((...args) => {
const now = Date.now();
const elapsed = now - lastRan.current;
if (elapsed >= interval) {
callbackRef.current(...args);
lastRan.current = now;
} else {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
lastRan.current = Date.now();
}, interval - elapsed);
}
}, [interval]);
useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return throttledFn;
}
Composing Hooks Together
Custom hooks can call other custom hooks. This is the composability superpower.
function useSearchWithDebounce(endpoint) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const { data, loading, error } = useFetch(
debouncedQuery ? `${endpoint}?q=${debouncedQuery}` : null
);
const [recentSearches, setRecentSearches] = useLocalStorage('recent-searches', []);
const search = useCallback((term) => {
setQuery(term);
if (term && !recentSearches.includes(term)) {
setRecentSearches(prev => [term, ...prev].slice(0, 10));
}
}, [recentSearches, setRecentSearches]);
return {
query,
search,
results: data,
loading,
error,
recentSearches,
};
}
+---------------------------+
| useSearchWithDebounce |
| |
| +---------------------+ |
| | useState (query) | |
| +---------------------+ |
| | useDebounce | |
| | +----------------+ | |
| | | useState | | |
| | | useEffect | | |
| | +----------------+ | |
| +---------------------+ |
| | useFetch | |
| | +----------------+ | |
| | | useState x3 | | |
| | | useEffect | | |
| | +----------------+ | |
| +---------------------+ |
| | useLocalStorage | |
| | +----------------+ | |
| | | useState | | |
| | | useEffect | | |
| | +----------------+ | |
| +---------------------+ |
+---------------------------+
Hook Testing
Custom hooks should be tested in isolation using @testing-library/react-hooks or the renderHook utility from @testing-library/react.
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('returns initial value when nothing is stored', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('default');
});
it('reads existing value from localStorage', () => {
window.localStorage.setItem('key', JSON.stringify('stored'));
const { result } = renderHook(() => useLocalStorage('key', 'default'));
expect(result.current[0]).toBe('stored');
});
it('updates localStorage on setValue', () => {
const { result } = renderHook(() => useLocalStorage('key', 'default'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(JSON.parse(window.localStorage.getItem('key'))).toBe('updated');
});
it('supports functional updates', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
act(() => {
result.current[1](prev => prev + 1);
});
expect(result.current[0]).toBe(1);
});
});
When to Extract a Custom Hook
| Signal | Action |
|---|---|
Same useState + useEffect combo in 2+ components | Extract to hook |
| Component has 5+ hook calls with related logic | Group into one hook |
| You want to test stateful logic without rendering | Extract to hook |
| Side effect cleanup is complex | Encapsulate in hook |
| Logic does not need JSX | It belongs in a hook, not a component |
Do not extract prematurely. If the logic appears in only one component and is unlikely to be reused, a custom hook adds indirection without benefit. Wait until you see the pattern repeat or the component becomes hard to follow.
Common Mistakes
Returning Unstable References
// BAD: returns a new object every render, breaking memoization downstream
function useUser(id) {
const [user, setUser] = useState(null);
useEffect(() => { /* fetch user */ }, [id]);
return { user, loading: !user }; // new object every render
}
// BETTER: use useMemo if consumers depend on referential equality
function useUser(id) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => { /* fetch user */ }, [id]);
return useMemo(() => ({ user, loading }), [user, loading]);
}
Ignoring Cleanup
// BAD: no cleanup, subscriptions and timers leak
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handler = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handler);
// Missing: return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
Always return a cleanup function from useEffect when adding event listeners, timers, or subscriptions.
Over-Abstracting
// TOO ABSTRACT: What does this hook even do?
function useData(config) {
// 200 lines of generalized logic
// handles fetch, cache, retry, polling, auth, pagination, websockets...
}
// BETTER: separate concerns into focused hooks
function useFetch(url) { /* ... */ }
function usePolling(callback, interval) { /* ... */ }
function usePagination(fetchFn) { /* ... */ }
Keep hooks focused on one responsibility. Compose small hooks into larger ones when needed.
TypeScript Custom Hooks
Adding types to custom hooks improves developer experience significantly.
import { useState, useEffect } from 'react';
type FetchState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
function useFetch<T>(url: string | null): FetchState<T> & { refetch: () => void } {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: !!url,
error: null,
});
const fetchData = async () => {
if (!url) return;
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: T = await res.json();
setState({ data: json, loading: false, error: null });
} catch (err) {
setState(prev => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
}));
}
};
useEffect(() => { fetchData(); }, [url]);
return { ...state, refetch: fetchData };
}
// Usage with type inference
type User = { id: number; name: string; email: string };
const { data } = useFetch<User>('/api/user/1');
// data is User | null, fully typed
Summary
| Hook | Purpose | Key Feature |
|---|---|---|
useFetch | Data fetching | Abort controller, caching, refetch |
useLocalStorage | Persistent state | SSR safe, cross-tab sync |
useForm | Form management | Validation, touched state, getFieldProps |
useDebounce | Delay execution | Waits for input to stop |
useThrottle | Rate limiting | Fires at regular intervals |
Custom hooks are React's composition model. They let you share logic between components without changing the component hierarchy. Start simple, add features as needed, test in isolation, and always respect the Rules of Hooks.
Key Takeaways
- Custom hooks are functions starting with
usethat call other hooks - Each component calling a hook gets its own independent state copy
- Always clean up subscriptions, timers, and listeners in
useEffect - Use
AbortControllerin fetch hooks to cancel stale requests - Guard for SSR with
typeof window === 'undefined' - Compose small hooks into larger ones rather than building monoliths
- Do not extract a hook until the pattern repeats or the component is hard to follow
- Add TypeScript generics for type-safe reusable hooks
- Configure
eslint-plugin-react-hooksto enforce rules automatically