Building Custom Hooks
When you find yourself copying and pasting the same useState and useEffect logic between components, that’s a signal to extract it into a custom hook. Custom hooks let you package stateful logic into a reusable function that any component can call. This tutorial walks through how to design, write, and test them.
What Is a Custom Hook?
A custom hook is any JavaScript function whose name starts with use that calls at least one React hook. That’s the whole definition. React has no special API for detecting them — the use prefix is a convention that linters and developers use to identify hooks.
The key insight is that hooks give you a way to attach behavior to React’s rendering lifecycle without using class components, render props, or higher-order components. When you extract logic into a hook, you get the same isolation benefits as a component — each call to a hook gets its own state — but without the rendering overhead.
function useWarmth() {
const [temperature, setTemperature] = useState(20);
return { temperature, heat: () => setTemperature(t => t + 1) };
}
// Each component that calls useWarmth has its own temperature
function Kitchen() {
const { temperature, heat } = useWarmth();
return <button onClick={heat}>Kitchen: {temperature}°C</button>;
}
function Bedroom() {
const { temperature, heat } = useWarmth();
return <button onClick={heat}>Bedroom: {temperature}°C</button>;
}
Clicking the kitchen button heats only the kitchen. Clicking the bedroom button heats only the bedroom. The state is isolated per hook call, not shared globally.
The Rules of Hooks
Custom hooks must follow the same two rules as built-in hooks.
Only call hooks at the top level. Don’t call them inside loops, conditions, or nested functions. React relies on the call order of hooks being identical across renders. If you call a hook conditionally — say, inside an if block that sometimes runs — then on a render where that condition is false, React’s internal list of state slots gets out of sync with the actual hooks being called.
// ❌ Wrong — hook inside a conditional
function Component({ showCounter }) {
if (showCounter) {
const [count, setCount] = useState(0); // breaks hook ordering
}
// ...
}
// ✅ Correct — always at top level
function Component({ showCounter }) {
const [count, setCount] = useState(0);
if (!showCounter) return null;
// ...
}
Only call hooks from React functions. A hook must be called from a function component or another custom hook. Calling useState from a plain utility function, an event handler, or a class component throws an error.
The ESLint plugin eslint-plugin-react-hooks enforces both rules automatically. Install it and treat warnings as errors.
Building a useToggle Hook
Let’s start with something simple. A useToggle hook manages a boolean value that flips between true and false.
import { useState, useCallback } from 'react';
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle];
}
The functional update form setValue(v => !v) always flips from the current value rather than relying on a captured closure value. This avoids a class of stale state bugs.
Usage is straightforward:
function App() {
const [isOn, toggle] = useToggle(false);
return (
<div>
<p>{isOn ? 'ON' : 'OFF'}</p>
<button onClick={toggle}>Flip</button>
</div>
);
}
Return an array because there are exactly two values and consumers typically name them individually.
Syncing State with localStorage
A common need is persisting state across page reloads. useLocalStorage wraps localStorage with the same interface as useState.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage might be unavailable (private browsing, full disk)
}
}, [key, value]);
return [value, setValue];
}
The useState initializer reads from localStorage only on the first render. This lazy initialization pattern avoids hitting storage on every render, which matters if you’re reading large values or have performance-sensitive rendering paths.
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<select
value={theme}
onChange={e => setTheme(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
Data Fetching with useFetch
Fetching data from an API involves managing three states: loading, error, and data. A useFetch hook encapsulates all three.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function load() {
if (url === null) {
setLoading(false);
return;
}
try {
setLoading(true);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err);
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
The cancelled flag is critical. It prevents the hook from updating state after the component unmounts or after the url changes before a previous fetch resolves. Without it, a slow response arriving after a component has unmounted produces a React warning about updating state on an unmounted component.
The null guard at the top of load() handles cases where the hook is called with no URL yet — for example, when a search component waits for user input before fetching.
Use it like this:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <h1>{user.name}</h1>;
}
Debouncing Values
Search inputs often need to wait for the user to stop typing before sending a request. useDebounce delays a value update until a timer expires.
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;
}
Every time value changes, the cleanup function cancels the previous timer and sets a new one. The debounced value only updates once the dust settles. This example pairs it with useFetch for a search input:
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data, loading } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <p>Searching...</p>}
{data && <ul>{data.map(item => <li key={item.id}>{item.name}</li>)}</ul>}
</div>
);
}
Composing Hooks Together
Custom hooks can call other custom hooks. This is where the pattern becomes powerful — you build small, focused hooks and combine them into more complex behavior.
function useUserPreferences(userId) {
const [prefs, setPrefs] = useState({});
const { data: user } = useFetch(`/api/users/${userId}`);
useEffect(() => {
if (user) {
setPrefs(user.defaultPrefs || {});
}
}, [user]);
const updatePref = useCallback((key, value) => {
setPrefs(p => ({ ...p, [key]: value }));
}, []);
return { prefs, updatePref };
}
This hook composes useFetch from earlier and adds its own state management on top. Any component that needs user preferences can call useUserPreferences without knowing anything about how the data is fetched or stored.
Testing Custom Hooks
Custom hooks need testing too. Use renderHook from @testing-library/react, which provides a test harness for running hooks outside of a real component.
import { renderHook, waitFor, act } from '@testing-library/react';
import { useToggle } from './useToggle';
test('useToggle starts with initial value', () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current[0]).toBe(true);
});
test('useToggle flips value on toggle', () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);
});
test('useToggle flips back', () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[1]();
result.current[1]();
});
expect(result.current[0]).toBe(false);
});
act() is essential — it flushes all pending state updates synchronously before running assertions. Without it, calling result.current[1]() and immediately asserting result.current[0] could catch the state mid-update.
For async hooks like useFetch, use waitFor to wait for loading and data states:
test('useFetch loads data', async () => {
global.fetch = jest.fn(() =>
Promise.resolve(new Response(JSON.stringify({ id: 1, name: 'Alice' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
);
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
});
Reset mocks in beforeEach to avoid state leaking between tests.
Common Gotchas
Stale closures are the most frequent hook bug. When you read a value from the enclosing scope inside a callback, that value is captured at the time the callback is created. If the value changes later but the callback still references the old copy, you have a stale closure.
// ❌ Stale — count is always 0 inside the interval
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // captures count from the first render
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — runs once
// ✅ Fixed — functional update always gets the current state
setCount(prev => prev + 1);
Forgetting cleanup in useEffect causes memory leaks and lingering timers. If your effect sets up a subscription, event listener, or timer, return a cleanup function that tears it down.
Lazy initialization matters for expensive first renders. If your initial state requires reading from localStorage, parsing a large JSON object, or running a computation, pass a function to useState instead of the value directly. The function runs only once, on the first render.
// ❌ Runs on every render if React ever re-evaluates (React.memo, etc.)
const [value] = useState(JSON.parse(localStorage.getItem('key')));
// ✅ Runs once, on the first render only
const [value] = useState(() => JSON.parse(localStorage.getItem('key')));
See Also
- React Components and JSX — the rendering side of hooks
- JavaScript Closures — the scope mechanics that underpin hook behavior
- JavaScript Promises — used internally by
useFetchand other async hooks