You're refactoring a component on a Friday afternoon. It's 200 lines long. Half of it is useState and useEffect calls for fetching data, tracking window size, debouncing input, managing localStorage. The actual JSX is maybe 30 lines at the bottom. Here's what's probably happening: you've got business logic tangled up with rendering logic, and custom hooks are how you untangle it.
I've been writing React full-time for a few years now, and I can tell you that the moment I started pulling logic into custom hooks was the moment my components stopped being nightmares. Before that, I had components where the return statement was a tiny island at the bottom of a 300-line sea of state management. It made code reviews painful and debugging even worse. The worst part: when I needed the same fetching pattern in a different component, I'd copy-paste it, swear I'd refactor later, and never do it.
A Custom Hook Is Just a Function
Starts with use. Calls other hooks. Returns whatever you want. That's the whole API.
import { useState, useEffect } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// Usage in any component
function Dashboard() {
const { width } = useWindowSize();
return width > 768 ? <DesktopView /> : <MobileView />;
}
That component went from "manages resize listeners and renders conditionally" to "renders conditionally." The resize stuff lives in its own function now, testable on its own, reusable anywhere.
One gotcha that bit me early on: window.innerWidth isn't available during server-side rendering. If you're using Next.js or any SSR framework, that initial useState call will blow up because window doesn't exist on the server. The fix is straightforward but easy to forget:
const [size, setSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
});
I once spent 45 minutes debugging a hydration mismatch in a Next.js project because the server rendered the mobile layout (width was 0) and the client immediately re-rendered the desktop layout. The page would flash for a split second. Moving the initial render to a useEffect that only runs on the client fixed it, but it's the kind of thing you only discover by shipping broken code to staging.
useFetch
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 fetchData() {
try {
setLoading(true);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
The AbortController part matters more than you'd think. Without it, if your component unmounts while a fetch is in flight, React tries to call setState on something that no longer exists. You get that warning everyone ignores about "can't perform a state update on an unmounted component." The abort signal cancels the request when cleanup runs.
A subtlety that tripped me up: if url is null or undefined, you probably don't want the hook to fetch at all. The version above will try to fetch "null" as a literal URL and give you a network error. I usually guard against that:
useEffect(() => {
if (!url) {
setData(null);
setLoading(false);
return;
}
// ... rest of the fetch logic
}, [url]);
Usage is clean:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <ProfileCard user={user} />;
}
In a production app, you'd probably also want a refetch function so the consumer can manually trigger a re-fetch without changing the URL. You can do that by adding a counter to the dependency array:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url, retryCount]);
const refetch = () => setRetryCount(c => c + 1);
return { data, loading, error, refetch };
}
Bumping retryCount changes the dependency array, which triggers the effect again. It's a bit of a hack, but it works reliably and the intent is clear.
useLocalStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'dark');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
// Values persist across page refreshes
}
Drop-in replacement for useState that survives page refreshes. The lazy initializer (the function passed to useState) means you only read from localStorage once, on mount, not on every render.
There's a bug in this implementation that most tutorials don't mention. If two tabs have the same page open and one tab changes the localStorage value, the other tab's state doesn't update. The hook only reads localStorage on mount. If you need cross-tab sync, you have to listen for the storage event:
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
setStoredValue(JSON.parse(e.newValue));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
Another thing: JSON.parse can throw on corrupted data. If someone manually edits localStorage in DevTools and puts in malformed JSON, your app crashes on the next page load. That try/catch in the initializer handles it, but the setValue function should probably have one too. I've seen this happen in production more than once -- users with browser extensions that mess with localStorage values.
useDebounce
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Twelve lines. Saves you from hammering your API on every keystroke. Pair it with useFetch:
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)} />
<ResultsList results={results} />
</div>
);
}
Something I learned the hard way with this hook: if you're debouncing a search input, users expect immediate feedback that something is happening. A 500ms delay with no visual change makes the UI feel broken. I usually pair useDebounce with a loading indicator that shows as soon as the raw query changes, not when the debounced value updates. Something like const isWaiting = query !== debouncedQuery gives you a boolean you can use to show a subtle spinner or change the input border color.
usePrevious: Tracking the Last Value
This one comes up more than you'd expect. Sometimes you need to know what a prop or state value was on the previous render -- maybe to compare old and new values, run an animation, or log a change. React doesn't give you this out of the box, but a ref makes it trivial:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Usage
function PriceDisplay({ price }) {
const previousPrice = usePrevious(price);
const direction = previousPrice !== undefined
? price > previousPrice ? 'up' : price < previousPrice ? 'down' : 'same'
: 'same';
return (
<span className={`price price-${direction}`}>
${price.toFixed(2)}
</span>
);
}
The trick is that useEffect runs after render, so by the time it updates ref.current, the component has already rendered with the old value. On the next render, ref.current holds what was passed last time. It's a one-render delay, which is exactly what you want.
I used this on a dashboard project where stock prices updated via WebSocket. The price would flash green when it went up and red when it went down. Without usePrevious, I would have needed a separate piece of state plus a useEffect to compare and update -- way more code for the same result.
useIntersectionObserver
function useIntersectionObserver(options = {}) {
const [ref, setRef] = useState(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!ref) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, { threshold: 0.1, ...options });
observer.observe(ref);
return () => observer.disconnect();
}, [ref, options.threshold, options.rootMargin]);
return [setRef, isVisible];
}
// Usage
function LazyImage({ src, alt }) {
const [ref, isVisible] = useIntersectionObserver();
return (
<div ref={ref}>
{isVisible ? <img src={src} alt={alt} /> : <Placeholder />}
</div>
);
}
Lazy loading, infinite scroll triggers, "animate when visible" effects -- all the same hook. Notice it returns setRef (the state setter) instead of a ref object. You pass that as the ref prop and React calls it with the DOM node. Slightly weird pattern but it means the hook re-runs the effect when the element actually mounts.
One thing I'll flag: if you're using this for infinite scroll, you probably want a variation that only fires once. Otherwise, the observer keeps toggling isVisible as you scroll past and back. Add a triggerOnce option:
function useIntersectionObserver({ triggerOnce = false, ...options } = {}) {
const [ref, setRef] = useState(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!ref) return;
const observer = new IntersectionObserver(([entry]) => {
const visible = entry.isIntersecting;
setIsVisible(visible);
if (visible && triggerOnce) {
observer.unobserve(ref);
}
}, { threshold: 0.1, ...options });
observer.observe(ref);
return () => observer.disconnect();
}, [ref, triggerOnce, options.threshold, options.rootMargin]);
return [setRef, isVisible];
}
useMediaQuery: CSS Media Queries in JS
Sometimes you need to know about a media query in your JavaScript logic, not just your CSS. Maybe you render completely different component trees on mobile vs. desktop, or you want to disable an animation when the user has prefers-reduced-motion enabled.
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (event) => setMatches(event.matches);
mediaQuery.addEventListener('change', handler);
setMatches(mediaQuery.matches);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
function Navigation() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return isMobile ? <HamburgerMenu animated={!prefersReducedMotion} /> : <DesktopNav />;
}
This is cleaner than useWindowSize when all you care about is a breakpoint threshold. It uses the browser's native matchMedia API, which is more efficient than recalculating on every resize event because the browser only fires the callback when the query result actually changes.
When to Extract (And When Not To)
Not every useState + useEffect combo needs to become a hook. If the logic only appears in one component and it's short, leave it inline. Extracting a 4-line effect into its own file just adds indirection for no benefit.
Extract when you see the same pattern in two or more components. Extract when a component has so many hooks that you can't see the JSX without scrolling. Extract when you want to test the logic separately from the rendering.
Return objects when the hook gives back named things ({ data, loading, error }). Return arrays when position matters and you want renaming to be easy ([value, setValue] -- same convention as useState). Don't return more than 3-4 things in an array; switch to an object at that point.
I'll add a practical consideration: think about the naming. If your hook is called useData, that tells the next developer nothing. useFetchUsers is better. useUserSearch is better still. The name should tell you what it does without reading the implementation. I've worked on codebases with hooks named useHelper and useLogic -- don't be that person.
Testing Custom Hooks
One of the underrated benefits of custom hooks is testability. You can't easily test a useEffect buried inside a component without rendering the whole component and interacting with it. But a custom hook can be tested with @testing-library/react-hooks (or the renderHook function from @testing-library/react in newer versions):
import { renderHook, act } from '@testing-library/react';
test('useLocalStorage reads initial value', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
expect(result.current[0]).toBe('default');
});
test('useLocalStorage persists value', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
act(() => {
result.current[1]('updated');
});
expect(result.current[0]).toBe('updated');
expect(JSON.parse(window.localStorage.getItem('testKey'))).toBe('updated');
});
test('useDebounce delays value update', async () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } }
);
expect(result.current).toBe('hello');
rerender({ value: 'world', delay: 500 });
expect(result.current).toBe('hello'); // Not updated yet
act(() => jest.advanceTimersByTime(500));
expect(result.current).toBe('world'); // Now it updates
});
This is a massive improvement over testing the same logic inline. You don't need to render a component, click buttons, or wait for UI updates. You test the hook's behavior directly. That tight feedback loop makes it much easier to cover edge cases you'd otherwise skip.
Composing Hooks Together
The best part about custom hooks is that they compose. You already saw useDebounce + useFetch for the search bar. You can go further. A useApi hook that wraps useFetch with auth headers. A usePersistedState that combines useLocalStorage with useDebounce to avoid writing to disk on every keystroke. A usePagination that internally uses useFetch and manages page state.
Each hook stays small and focused. You build up complexity by plugging them together, not by making any single hook do too much.
Here's a concrete example -- a usePersistedSearch that combines three hooks we've already built:
function usePersistedSearch(storageKey, searchEndpoint) {
const [query, setQuery] = useLocalStorage(storageKey, '');
const debouncedQuery = useDebounce(query, 400);
const { data, loading, error } = useFetch(
debouncedQuery ? `${searchEndpoint}?q=${encodeURIComponent(debouncedQuery)}` : null
);
return {
query,
setQuery,
results: data,
loading,
error,
isDebouncing: query !== debouncedQuery
};
}
Three hooks composed into one. The search query persists across refreshes, API calls are debounced, and the consumer gets a simple interface. Each piece is independently testable and reusable.
Things That Caught Me Off Guard
- Hooks that return new object/array references on every render cause infinite re-render loops in consumers that use them in dependency arrays. Memoize your return values with
useMemoif this happens. - The
useprefix isn't optional. React's linter rules depend on it. Name your hookfetchDatainstead ofuseFetchDataand the exhaustive-deps rule stops working inside it. - You can't call hooks conditionally. This trips people up when they try to build a hook that "only runs sometimes." Move the condition inside the hook instead.
- Custom hooks don't share state between components. Every component that calls
useWindowSize()gets its own independent copy of the state. If you need shared state, you want context or an external store. - Cleanup functions in
useEffectrun on every re-render where the dependencies change, not just on unmount. I forget this about once a quarter and spend 20 minutes confused.
The mental shift with custom hooks is thinking of your components as thin rendering layers. All the interesting logic -- data fetching, subscriptions, derived state, browser API interactions -- lives in hooks. The component just calls them and renders based on what they return. Once that clicks, you'll start seeing opportunities to extract hooks everywhere. Which is fine, as long as you don't over-extract. A component with one useState and one useEffect doesn't need to be a hook. But a component with five of each probably does.
Comments (2)
The useDebounce hook alone saved me from a performance nightmare on a search feature. Was firing API calls on every keystroke before. This is so much cleaner than what I had.
useFetch with AbortController is the correct way to do it. Most tutorials skip the cleanup and you end up with state updates on unmounted components. Thanks for getting it right.