React Custom Hooks: Creating Your Own
Custom hooks are one of the most powerful and underutilized features in React. When React introduced hooks in version 16.8, it didn't just change how we write components — it fundamentally changed how we share stateful logic across an application. Custom hooks let you extract complex logic into reusable functions, making your components cleaner, your code more testable, and your team more productive. In this guide, we'll go from the basics of what a custom hook is, through building real-world hooks you'll actually use in production.
You should be comfortable with React's built-in hooks — especially useState, useEffect, useRef, and useCallback — before building custom hooks. Custom hooks are essentially compositions of these primitives, so a solid understanding of the fundamentals makes everything click.
What Is a Custom Hook?
A custom hook is simply a JavaScript function whose name starts with use and that can call other hooks. That's it. There's no special API, no magic syntax — just a convention React uses to identify hook calls and enforce the rules of hooks.
Here's why that simple convention is so powerful: before hooks, sharing stateful logic required render props or higher-order components — patterns that led to deeply nested component trees and hard-to-trace data flows. Custom hooks let you extract stateful logic into a standalone function that any component can call directly, without changing your component hierarchy at all.
The Problem Custom Hooks Solve
Consider this common scenario: you have multiple components that each need to fetch data from an API, manage a loading state, and handle errors. Without custom hooks, you duplicate this logic in every component:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <div>{user.name}</div>;
}
Now imagine copy-pasting those 20 lines into every component that fetches data. Bugs get fixed in one place but not others. Custom hooks eliminate this entirely.
Your First Custom Hook: useFetch
Let's extract that fetch logic into a reusable useFetch hook. The key insight is that hooks are just functions — you move the state and effects into the function and return whatever the calling component needs.
Create the hook file
By convention, put custom hooks in a hooks/ directory. Name the file useFetch.js and the function useFetch.
Move state and effects in
Copy the useState and useEffect calls from the component directly into the function body.
Return what consumers need
Return an object (or array) with the values and functions the component needs to render and interact.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Abort controller prevents state updates on unmounted components
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return res.json();
})
.then(json => {
setData(json);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
// Cleanup: abort the request when URL changes or component unmounts
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
export default useFetch;
Now any component can use it in one line:
import useFetch from './hooks/useFetch';
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <div>{user.name}</div>;
}
The component went from 25 lines to 8. More importantly, the AbortController cleanup (which many developers forget) is now handled automatically in every component that uses useFetch.
useLocalStorage: Persistent State
One of the most useful hooks you can build is useLocalStorage — it works exactly like useState but persists the value to localStorage automatically. This is perfect for user preferences, theme settings, form drafts, and anything else that should survive a page refresh.
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Initialize state from localStorage if available
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
// Parse stored JSON or fall back to initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`useLocalStorage: error reading key "${key}":`, error);
return initialValue;
}
});
// Persist to localStorage whenever state changes
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`useLocalStorage: error writing key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
export default useLocalStorage;
If you're using Next.js or any server-side rendering framework, window is not available on the server. Guard window.localStorage calls inside useEffect (which only runs on the client) or check typeof window !== 'undefined' before accessing it.
Usage is identical to useState — it's a drop-in replacement with persistence:
import useLocalStorage from './hooks/useLocalStorage';
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
useDebounce: Taming Rapid Input
Debouncing is the technique of delaying execution until a user has stopped performing an action for a set period. It's essential for search inputs — you don't want to fire an API request on every keystroke. useDebounce is the cleanest way to add this to any component.
import { useState, useEffect } from 'react';
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timer if value changes before delay elapses
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
export default useDebounce;
import { useState } from 'react';
import useDebounce from './hooks/useDebounce';
import useFetch from './hooks/useFetch';
function SearchBar() {
const [query, setQuery] = useState('');
// Only updates 400ms after the user stops typing
const debouncedQuery = useDebounce(query, 400);
// useFetch only re-runs when debouncedQuery changes
const { data: results, loading } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <Spinner />}
{results?.map(r => <ResultItem key={r.id} result={r} />)}
</div>
);
}
Notice how we compose useDebounce with useFetch — this is a major benefit of the hook pattern. Hooks compose naturally just like regular functions.
Custom hooks sit between your components and React's built-in hooks, encapsulating reusable logic.
useWindowSize: Responsive Logic in JS
Sometimes CSS media queries aren't enough — you need your JavaScript to know the window dimensions too. Maybe you're conditionally rendering a mobile menu, or calculating the number of columns in a grid. useWindowSize provides live window dimensions that update on resize.
import { useState, useEffect } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener('resize', handleResize);
// Cleanup removes the listener when component unmounts
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
export default useWindowSize;
import useWindowSize from './hooks/useWindowSize';
function Navbar() {
const { width } = useWindowSize();
const isMobile = width < 768;
return (
<nav>
<Logo />
{isMobile ? <HamburgerMenu /> : <DesktopNav />}
</nav>
);
}
usePrevious: Tracking Previous Values
React doesn't give you the previous value of a prop or state out of the box, but it's a common need — for example, animating between the old and new value, or detecting which direction a counter changed. usePrevious uses a useRef to capture the value before each render.
import { useRef, useEffect } from 'react';
function usePrevious(value) {
const ref = useRef();
// useEffect runs after render, so ref.current still holds
// the previous value during the render where value changed
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
export default usePrevious;
import { useState } from 'react';
import usePrevious from './hooks/usePrevious';
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count} | Previous: {prevCount ?? 'none'}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
);
}
useToggle: Boolean State Made Elegant
Toggling boolean state is something every UI does — modals, dropdowns, dark mode, accordion panels. The pattern is repetitive enough that it deserves its own hook.
import { useState, useCallback } from 'react';
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
// useCallback ensures toggle is the same function reference
// across renders — safe to pass as a prop or useEffect dep
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return [value, toggle, setTrue, setFalse];
}
export default useToggle;
import useToggle from './hooks/useToggle';
function Modal() {
const [isOpen, toggleModal, openModal, closeModal] = useToggle(false);
return (
<div>
<button onClick={openModal}>Open Modal</button>
{isOpen && (
<div className="modal">
<p>Modal content here</p>
<button onClick={closeModal}>Close</button>
</div>
)}
</div>
);
}
When to Extract a Custom Hook
Not every piece of logic needs its own hook. Here's how to decide:
| Signal | Extract to Hook? | Reason |
|---|---|---|
| Same state + effect pattern in 2+ components | Yes | DRY principle, single fix point |
| Complex logic making component hard to read | Yes | Separation of concerns |
| Logic needs to be independently tested | Yes | Hooks are testable without rendering |
| Simple one-liner in a single component | No | Premature abstraction adds indirection |
| Logic is only about rendering (no state/effects) | No | Extract as a utility function, not a hook |
| Logic uses no hooks internally | No | Doesn't need to be a hook — name it without "use" |
Testing Custom Hooks
Custom hooks are significantly easier to test than component logic because you can test the hook in isolation using @testing-library/react's renderHook utility. You get the hook's return values directly without needing to render any UI.
import { renderHook, act } from '@testing-library/react';
import useToggle from './useToggle';
describe('useToggle', () => {
test('initializes with the provided value', () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current[0]).toBe(true);
});
test('toggles the value', () => {
const { result } = renderHook(() => useToggle(false));
const [, toggle] = result.current;
act(() => toggle());
expect(result.current[0]).toBe(true);
act(() => toggle());
expect(result.current[0]).toBe(false);
});
test('setTrue always sets to true', () => {
const { result } = renderHook(() => useToggle(false));
const [, , setTrue] = result.current;
act(() => setTrue());
expect(result.current[0]).toBe(true);
// Calling setTrue again should still be true
act(() => setTrue());
expect(result.current[0]).toBe(true);
});
});
act()In tests, any action that causes state updates (calling toggle, calling setTrue, simulating events) must be wrapped in act(). This tells React Testing Library to flush all effects and re-renders before you make assertions. Skipping this leads to flaky tests and console warnings.
Advanced: useReducer-Based Hooks
When a hook's internal state has multiple related fields that change together, useReducer is cleaner than multiple useState calls. Here's a more robust useFetch rewritten with useReducer:
import { useReducer, useEffect } from 'react';
const initialState = { data: null, loading: false, error: null };
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { data: null, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { data: null, loading: false, error: action.payload };
default:
return state;
}
}
function useFetch(url) {
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
dispatch({ type: 'FETCH_START' });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
});
return () => controller.abort();
}, [url]);
return state;
}
export default useFetch;
The useReducer approach makes state transitions explicit and eliminates the "impossible state" problem — for example, it's no longer possible to have loading: true and error: "something" at the same time, because each action replaces the entire state.
Composing Multiple Custom Hooks
One of the best things about custom hooks is how naturally they compose. Here's a real-world example combining useDebounce, useFetch, and useLocalStorage into a cached search experience:
import useDebounce from './useDebounce';
import useFetch from './useFetch';
import useLocalStorage from './useLocalStorage';
function useSearch(endpoint) {
const [query, setQuery] = useLocalStorage('lastSearch', '');
const debouncedQuery = useDebounce(query, 400);
const url = debouncedQuery.trim()
? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}`
: null;
const { data: results, loading, error } = useFetch(url);
return {
query,
setQuery,
results: results ?? [],
loading,
error,
};
}
export default useSearch;
This useSearch hook automatically persists the search query to localStorage, debounces API calls, and handles loading/error states — all in 18 lines. Any component that needs search functionality gets all of this for free.
Key Takeaways
- Custom hooks are just functions — they follow the
usenaming convention and can call other hooks internally. - Extract when you see duplication — if the same state + effect pattern appears in two components, it belongs in a hook.
- Always clean up effects — return a cleanup function from
useEffectto cancel subscriptions, abort fetches, and clear timers. - Compose hooks naturally — hooks call other hooks just like functions call other functions, enabling powerful compositions.
- Test hooks with
renderHook— you get clean, isolated tests that don't depend on component rendering. - Use
useReducerfor complex state — when multiple state fields change together, a reducer makes transitions explicit and eliminates impossible states. - Don't over-abstract — a single use of a pattern doesn't warrant a custom hook. Wait for the second occurrence before extracting.
Conclusion
Custom hooks are React's answer to the age-old question of how to reuse stateful logic. They give you the power of higher-order components and render props without the awkward nesting, and they make your component code dramatically cleaner in the process. The hooks we built here — useFetch, useLocalStorage, useDebounce, useWindowSize, usePrevious, and useToggle — are genuinely useful in production and represent the most common patterns you'll encounter.
As you build more React applications, you'll develop an instinct for when logic deserves its own hook. Start by watching for copy-pasted state and effect patterns, extract them, and you'll find your components becoming focused, readable, and easy to test. The custom hook you write today might be the building block that saves your team hours next month.
