Custom Hooks in React: Extracting Reusable Logic the Right Way
1. Overview
When multiple components in a React application need the same piece of logic — calling APIs, reading localStorage, debouncing input — you easily end up copy-pasting state and useEffect back and forth. This article shows you how to wrap that logic in a custom hook: a function starting with use, leveraging built-in hooks (useState, useEffect, useRef…) for reuse. After reading, you’ll be able to write useFetch, useLocalStorage, useDebounce and know how to test them with renderHook.
2. The problem: repeated logic between components
Suppose you have two components that both need to call an API and manage loading/error:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...render
}
CustomerList and OrderList contain nearly identical logic, differing only in the URL. If tomorrow you want to add retry or cancel request handling, you have to fix it everywhere — this is a sign you need a custom hook.
3. What is a custom hook
A custom hook is a plain JavaScript function, whose name starts with use, that calls other React hooks inside. It doesn’t return JSX — it returns state and functions, like a “mini store” attached to the component using it.
Note: Each time a component calls a custom hook, React creates a separate state instance. Two components using
useFetchdon’t share state with each other — they only share logic.
4. Writing the first custom hook: useFetch
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
Explanation:
cancelledis used to avoid race condition: ifurlchanges before the old request finishes, the old result won’t be set into state anymore.- The cleanup function (
return () => { cancelled = true }) runs when the component unmounts orurlchanges — matching the lifecycle ofuseEffect. - The hook returns an object
{ data, loading, error }for easy destructuring at the call site.
Now your component becomes much simpler:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// render logic is similar, no more repeated fetch logic
}
5. Second example: useLocalStorage
Syncing state with localStorage is another piece of logic that gets copy-pasted a lot:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
Explanation:
useState(() => ...)uses a lazy initializer — the function only runs once when mounting, doesn’t readlocalStorageagain on each render.- The hook returns a tuple
[value, setValue]just likeuseState, helping users feel familiar right away.
Using it in a component:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
5.1. When state depends on the previous value
If you need to update based on the old value (like a counter), use functional updates to avoid stale closure:
setValue((prev) => prev + 1); // safer than setValue(value + 1)
6. Third example: useDebounce
Debouncing a search input is a very common problem:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Explanation:
- Each time
valuechanges, a newsetTimeoutis created; the oldsetTimeoutis cleared in the cleanup. - Only when the user stops typing within
delayMsdoesdebouncedupdate.
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. Naming rules and Rules of Hooks
Custom hooks must follow Rules of Hooks like built-in hooks:
- Name starts with
use— the ESLint plugineslint-plugin-react-hooksrelies on this prefix to check the rule; if there’s nouse, it won’t recognize this as a hook. - Only call hooks at the top-level of the function, not inside
if,for, or callbacks. - Only call hooks inside function components or other custom hooks, not inside regular functions.
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Enable exhaustive-deps to help you not forget dependencies in your hook’s useEffect — the most common cause of “stale data” bugs.
8. Testing custom hooks
Since custom hooks don’t return JSX, you can’t render them like components. The @testing-library/react library provides renderHook to call hooks in a test environment.
<!-- Not applicable for React projects; dependency installed via npm, see below -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('only updates value after delay ends', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // hasn't changed yet
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // debounce complete
vi.useRealTimers();
});
});
Explanation:
renderHooktakes a function calling a hook, returnsresult.current— the value the hook returns in the current render.rerendersimulates component re-render with new props (likevaluechanging when the user types).vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))lets you control time without waiting for real time; tests run fast and stable.
✓ useDebounce > only updates value after delay ends
9. Common mistakes and when not to use
Common mistakes:
- Forgetting cleanup in
useEffect: withuseFetch, if there’s nocancelledflag, an old request returning later could overwrite new data — causing incorrect display. - Missing dependency: if
useEffectin the hook uses an external variable but doesn’t declare it in the dependency array, the hook will use the “old” value (stale closure). - Returning a new object each render unnecessarily: if a hook returns
{ data, loading, error }as an object literal each time, a component usingReact.memobelow might re-render wastefully because the reference changes. Can useuseMemoif you measure a real problem.
When NOT to write a custom hook:
- Logic is used in only one place and has no reuse potential — extracting it only makes code harder to follow (have to jump to another file to understand).
- Logic is purely computation (pure function), unrelated to state/effect — should write a regular JS function, no need for a hook.
- When you need to share state (not just logic) between multiple components in different tree positions — custom hooks can’t do this, you need Context or a state management library (Zustand, Redux…).
Note: Custom hooks solve the problem of duplicate logic, not the problem of sharing state between components. Don’t confuse these two problems.
10. Conclusion
Custom hooks are a tool to extract stateful logic from components, making code cleaner, easier to test, and reusable. You just need to remember: name starts with use, follow Rules of Hooks, and test with renderHook instead of render. With useFetch, useLocalStorage, useDebounce above, you already have a framework to write more hooks like useMediaQuery, useForm in the same spirit of “write once, use everywhere”.