Custom Hooks Design Patterns
Custom Hooks Extract Reusable Logic
Once you get comfortable with hooks, you'll start noticing the same patterns showing up across components. A custom hook is simply a function that starts with use and calls other hooks. It extracts logic that multiple components share — without sharing state. Each component that calls the hook gets its own independent copy.
// Repeated logic in two components:
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => { /* fetch user */ }, []);
// ... same pattern in 10 more components
}
// Extracted into a custom hook:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(data => { if (!ignore) { setData(data); setLoading(false); } })
.catch(err => { if (!ignore) { setError(err); setLoading(false); } });
return () => { ignore = true; };
}, [url]);
return { data, loading, error };
}
// Clean usage:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
}
Think of a custom hook as a recipe card that you can hand to any cook (component). The recipe describes the steps (hooks to call, logic to run) but each cook has their own ingredients (independent state). Giving the same recipe card to two cooks produces two independent meals — they do not share a pot. Custom hooks share logic, not state.
Naming Conventions
// Hook that returns state: use + noun
function useOnlineStatus() { /* ... */ }
function useWindowSize() { /* ... */ }
function useLocalStorage(key) { /* ... */ }
// Hook that handles a behavior: use + verb/action
function useFetch(url) { /* ... */ }
function useDebounce(value, delay) { /* ... */ }
function useClickOutside(ref, handler) { /* ... */ }
// Hook that combines both: descriptive phrase
function useFormValidation(schema) { /* ... */ }
function usePaginatedQuery(url) { /* ... */ }
The use prefix is not just convention — it tells React's linter to enforce the Rules of Hooks inside the function, and it tells developers that calling order matters.
Return Value Patterns
Tuple (for single state + setter)
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle]; // Array destructuring allows custom names
}
const [isOpen, toggleOpen] = useToggle();
const [isActive, toggleActive] = useToggle(true);
Object (for multiple related values)
function useFetch(url) {
// ... fetch logic
return { data, loading, error, refetch };
}
const { data, loading, error } = useFetch('/api/users');
When to Choose Which
| Pattern | When | Why |
|---|---|---|
Tuple [value, setter] | Two values, mirroring useState | Allows renaming via destructuring |
Object { data, loading } | Three or more values | Named fields are self-documenting |
| Single value | One computed value | Simplest possible API |
Composition: Hooks That Use Hooks
This is where custom hooks get really powerful.
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function useDebouncedSearch(query) {
const debouncedQuery = useDebounce(query, 300); // Composes useDebounce
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
); // Composes useFetch
return { results: data, loading, error };
}
// Usage — clean, declarative:
function SearchPage() {
const [query, setQuery] = useState('');
const { results, loading } = useDebouncedSearch(query);
}
Each hook is a small, focused building block. Compose them into higher-level hooks that solve specific problems.
Pattern: Configuration Object
When your hook starts accepting more than 2-3 parameters, it's time to switch to a configuration object:
function useInfiniteScroll({
fetchFn,
pageSize = 20,
threshold = 200,
enabled = true,
}) {
const [items, setItems] = useState([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(async () => {
if (loading || !hasMore || !enabled) return;
setLoading(true);
const newItems = await fetchFn({ page: page + 1, pageSize });
setItems(prev => [...prev, ...newItems]);
setPage(p => p + 1);
setHasMore(newItems.length === pageSize);
setLoading(false);
}, [loading, hasMore, enabled, page, pageSize, fetchFn]);
useEffect(() => {
if (!enabled) return;
function handleScroll() {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMore();
}
}
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore, threshold, enabled]);
return { items, loading, hasMore, loadMore };
}
Pattern: Hook with Reducer
For complex internal state, combine useReducer with the custom hook:
function useAsync(asyncFn) {
const [state, dispatch] = useReducer(asyncReducer, {
status: 'idle',
data: null,
error: null,
});
const execute = useCallback(async (...args) => {
dispatch({ type: 'PENDING' });
try {
const data = await asyncFn(...args);
dispatch({ type: 'RESOLVED', data });
return data;
} catch (error) {
dispatch({ type: 'REJECTED', error });
throw error;
}
}, [asyncFn]);
return { ...state, execute };
}
function asyncReducer(state, action) {
switch (action.type) {
case 'PENDING': return { status: 'pending', data: null, error: null };
case 'RESOLVED': return { status: 'resolved', data: action.data, error: null };
case 'REJECTED': return { status: 'rejected', data: null, error: action.error };
default: return state;
}
}
The useEvent pattern for stable callbacks
A common pattern in custom hooks: accepting a callback that should not be in the dependency array:
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}The ref holds the latest callback without adding it to the effect's deps. This prevents re-creating the interval every time the callback changes — only delay changes trigger a new interval.
Production Scenario: Full-Featured Form Hook
function useForm({ initialValues, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const handleChange = useCallback((field, value) => {
setValues(prev => ({ ...prev, [field]: value }));
if (touched[field]) {
const fieldError = validate?.({ ...values, [field]: value })?.[field];
setErrors(prev => ({ ...prev, [field]: fieldError || '' }));
}
}, [values, touched, validate]);
const handleBlur = useCallback((field) => {
setTouched(prev => ({ ...prev, [field]: true }));
const fieldError = validate?.(values)?.[field];
setErrors(prev => ({ ...prev, [field]: fieldError || '' }));
}, [values, validate]);
const handleSubmit = useCallback(async (e) => {
e?.preventDefault();
const formErrors = validate?.(values) || {};
setErrors(formErrors);
setTouched(Object.fromEntries(Object.keys(values).map(k => [k, true])));
if (Object.values(formErrors).some(Boolean)) return;
setSubmitting(true);
try {
await onSubmit(values);
} finally {
setSubmitting(false);
}
}, [values, validate, onSubmit]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
submitting,
handleChange,
handleBlur,
handleSubmit,
reset,
getFieldProps: (field) => ({
value: values[field],
onChange: (e) => handleChange(field, e.target.value),
onBlur: () => handleBlur(field),
}),
};
}
Custom hooks share logic, not state. If two components call useToggle(), they get independent toggle states. If you need shared state between components, use Context or an external store. A common mistake is expecting custom hooks to act like singletons.
| What developers do | What they should do |
|---|---|
| Creating hooks that do too much — 200+ lines with 10 state variables Large hooks are hard to test, hard to reuse, and hard to understand. Break them into composable pieces. | Compose small, focused hooks. Each hook has one responsibility. |
| Expecting two components using the same hook to share state Hooks are functions. Each call creates new state objects. There is no hidden singleton. | Each hook call creates independent state. Use Context or external stores for shared state. |
| Not memoizing callbacks returned from hooks Without memoization, consumers get new function references every render, breaking their own memoization. | Wrap returned functions in useCallback if consumers might use them in deps or pass to memo components |
| Naming hooks without the 'use' prefix: fetchData(), toggle() The 'use' prefix enables the linter to enforce Rules of Hooks. Without it, conditional calls and loop calls would not be caught. | Always prefix with 'use': useFetchData(), useToggle() |
- 1Custom hooks share logic, not state — each call creates independent instances
- 2Always prefix with 'use' — it enables linting for Rules of Hooks
- 3Compose small hooks into complex ones — each hook has one responsibility
- 4Return tuples for 2 values, objects for 3+ values
- 5Memoize returned callbacks with useCallback so consumers can depend on them safely
Challenge: Build a useMediaQuery Hook
Challenge: Media Query Subscription
// Build a custom hook that subscribes to a CSS media query
// and returns whether it currently matches.
// It should update when the viewport changes.
function useMediaQuery(query) {
// Your implementation here
}
// Usage:
function Layout() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return (
<div>
{isMobile ? <MobileNav /> : <DesktopNav />}
</div>
);
}
Show Answer
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
function handleChange(e) {
setMatches(e.matches);
}
// Set initial value (handles SSR hydration)
setMatches(mql.matches);
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
}, [query]);
return matches;
}Design decisions:
- Lazy initializer for SSR compatibility (returns false on server)
- Updates on
changeevent, not on resize (more efficient — matchMedia fires only when the query crosses the threshold) - Effect re-runs when
querychanges (different media query string) - Returns a simple boolean — the simplest possible API
- Could also use
useSyncExternalStorefor concurrent safety