πͺ Creating Custom Hooks
Once two components need the same stateful logic, it's time to extract it. A custom hook is just a function that uses other hooks β the cleanest way React gives you to share behavior without sharing UI.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a custom hook is and when to reach for one
- Follow the rules and naming conventions (
use*, top-level calls) - Build practical hooks:
useLocalStorage,useFetch,useDebounce,useWindowSize - Compose hooks together and understand how to test them
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Write a useMediaQuery hook and use it to build a responsive component.
In This Lesson
Why Custom Hooks?
Imagine two components that both read and write a value to localStorage, both with the same useState + useEffect dance. Copy-pasting that logic is how bugs multiply. A custom hook lets you lift that logic into one reusable function.
π‘ The key idea: Custom hooks share stateful logic, not state itself. Each component that calls your hook gets its own independent state β just like calling useState directly.
π Definition
Custom hook: a JavaScript function whose name starts with use and that calls one or more other hooks (useState, useEffect, useRef, β¦).
Rules & Conventions
- Name it
useSomething. Theuseprefix is how React (and its linter) knows to enforce the Rules of Hooks. - Only call hooks at the top level. Never inside conditions, loops, or nested functions β call order must be stable between renders.
- Only call hooks from React functions β components or other hooks, not plain utilities.
- Return whatever shape fits. A tuple
[value, setValue]mirrorsuseState; an object{ data, loading, error }reads well when there are several named values.
Your First Hook: useLocalStorage
Let's turn the localStorage pattern into a hook that behaves just like useState, but persists across reloads:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage full or unavailable β ignore */
}
}, [key, value]);
return [value, setValue];
}
// Usage β identical ergonomics to useState:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
}
β Notice the lazy initializer
Passing a function to useState(() => β¦) means the localStorage read runs only on the first render, not every render.
A Data-Fetching Hook
Data fetching is the classic case for a custom hook β it bundles loading, error, and data state, plus cleanup to avoid setting state after unmount:
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();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err); })
.finally(() => setLoading(false));
return () => controller.abort(); // cancel on unmount / url change
}, [url]);
return { data, loading, error };
}
// Usage:
function UserCard({ id }) {
const { data, loading, error } = useFetch(`/api/users/${id}`);
if (loading) return <p>Loadingβ¦</p>;
if (error) return <p>Error: {error.message}</p>;
return <h3>{data.name}</h3>;
}
β οΈ Don't forgetres.ok.fetchonly rejects on network failure β a 404 or 500 still resolves. Throwing on!res.okis what turns HTTP errors into catchable errors.
More Everyday Hooks
useDebounce β delay a fast-changing value
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// Great for search boxes:
const debouncedQuery = useDebounce(query, 400);
const { data } = useFetch(`/api/search?q=${debouncedQuery}`);
useWindowSize β track the viewport
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const onResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return size;
}
β οΈ Always clean up
Every subscription β timers, event listeners, observers β needs a cleanup function returned from useEffect, or you'll leak listeners and update unmounted components.
Composing & Testing
Because hooks are just functions, one custom hook can call another. That's composition: a useSearch hook might use useDebounce and useFetch together, exposing a single tidy API to components.
function useSearch(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const debounced = useDebounce(query, 400);
const { data, loading } = useFetch(`/api/search?q=${debounced}`);
return { query, setQuery, results: data ?? [], loading };
}
To test a hook, render it inside a test component (or use renderHook from React Testing Library) and assert on what it returns as state updates:
import { renderHook, act } from '@testing-library/react';
test('useLocalStorage persists value', () => {
const { result } = renderHook(() => useLocalStorage('k', 0));
act(() => result.current[1](5));
expect(result.current[0]).toBe(5);
expect(JSON.parse(localStorage.getItem('k'))).toBe(5);
});
Hands-on Exercise
ποΈ Build a useMediaQuery Hook
Objective: Return a boolean that tracks whether a CSS media query currently matches.
Instructions:
- Accept a query string like
'(min-width: 768px)'. - Use
window.matchMediaand subscribe to itschangeevent. - Return the current
matchesboolean, and clean up the listener on unmount. - Use it to render a desktop vs. mobile layout.
π‘ Hint
Initialize state from window.matchMedia(query).matches, then add a listener that calls setMatches(e.matches).
β Solution
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
mql.addEventListener('change', onChange);
setMatches(mql.matches);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}
function Layout() {
const isDesktop = useMediaQuery('(min-width: 768px)');
return isDesktop ? <DesktopNav /> : <MobileNav />;
}
π― Quick Quiz
Question 1: What makes a function a React custom hook?
Question 2: Two components call the same useCounter() hook. Their counts areβ¦
Question 3: Why return a cleanup function from useEffect in a subscription hook?
Summary & Quiz
π Key Takeaways
- A custom hook is a
use*function that calls other hooks to share logic. - Each call gets its own independent state.
- Follow the Rules of Hooks: top-level calls, from React functions only.
- Bundle related state (data/loading/error) and always clean up subscriptions.
- Hooks compose β build small ones and combine them.
π Further Reading
π What's Next?
You've reached the end of the module's concepts. Next is the weekend project, where you'll build a complete React app that puts components, state, effects, and your own hooks together.
π Great work!
Custom hooks are the moment React "clicks" β you now think in reusable behavior, not copy-paste.