Custom Hooks That Earn Their Keep
Design real, reusable custom hooks that hide messy logic behind a clean, honest API.
You already know a custom hook is just a function starting with use that calls other hooks. The senior skill is knowing what to extract and what shape to return so the hook feels like a tiny, dependable library β not a leaky abstraction.
What makes a good custom hook
- One job.
useDebouncedValue,useLocalStorage,useOnScreenβ each does exactly one thing and names it well. - An honest return shape. Return a tuple when order is obvious (like
useState), an object when there are several named things a caller might want. - Stable identities. If you return functions, wrap them so they do not change every render β callers will put them in dependency arrays.
The smallest useful example
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
return [on, toggle];
}
// const [isOpen, toggleOpen] = useToggle();Notice the useCallback: toggle keeps the same identity across renders, so a memoized child or an effect depending on it will not churn.
Extract logic, not state
Every component that calls a hook gets its own independent state. Two components calling useToggle do not share a value β they share the behavior. That is the whole point, and it is why hooks replaced render props and HOCs for most logic reuse.
Example
import { useState, useEffect, useRef, useCallback } from 'react';
// A genuinely reusable hook: read + write a value in localStorage,
// synced across renders and stable across the tab.
function useLocalStorage(key, initialValue) {
// Lazy initializer: read storage exactly once, not on every render.
const [value, setValue] = useState(() => {
try {
const raw = window.localStorage.getItem(key);
return raw !== null ? JSON.parse(raw) : initialValue;
} catch {
return initialValue;
}
});
// Keep the latest key in a ref so the writer effect stays stable.
const keyRef = useRef(key);
keyRef.current = key;
useEffect(() => {
try {
window.localStorage.setItem(keyRef.current, JSON.stringify(value));
} catch {
// Storage full or blocked (private mode) β fail quietly.
}
}, [value]);
// Support the functional-updater form, just like useState.
const set = useCallback((next) => {
setValue((prev) => (typeof next === 'function' ? next(prev) : next));
}, []);
return [value, set];
}
function ThemePicker() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>
Theme: {theme}
</button>
);
}When to use it
- A team extracts all WebSocket reconnect and message queue logic into a useRealtimeChannel hook so feature components just call it and receive messages, unaware of the transport.
- An e-commerce app isolates cart operations (add, remove, persist to localStorage) in a useCart hook so the cart badge, product page, and checkout all share the same logic.
- An authentication module exposes a useAuth hook returning { user, login, logout, isLoading } so every protected component gets a clean, typed API with no knowledge of the token storage mechanism.
More examples
useDebounce hook
Returns a version of value that only updates after the user stops typing for delay ms β a single-job hook with a minimal, obvious API.
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;
}
// Usage
// const debouncedQuery = useDebounce(query, 400);useAsync for promise management
Wraps any async function and exposes status, data, error, and execute β hiding all state management from the caller.
import { useState, useCallback } from 'react';
function useAsync(asyncFn) {
const [state, setState] = useState({ status: 'idle', data: null, error: null });
const execute = useCallback((...args) => {
setState({ status: 'pending', data: null, error: null });
return asyncFn(...args)
.then(data => setState({ status: 'success', data, error: null }))
.catch(error => setState({ status: 'error', data: null, error }));
}, [asyncFn]);
return { ...state, execute };
}
// const { status, data, execute } = useAsync(fetchUser);useOnClickOutside hook
Subscribes to global pointer events and calls the handler when a click lands outside the referenced element.
import { useEffect } from 'react';
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = e => {
if (!ref.current || ref.current.contains(e.target)) return;
handler(e);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// const ref = useRef(); useOnClickOutside(ref, closeMenu);
Discussion