On this page
Custom Hooks: Reusable Stateful Logic
What Are Custom Hooks?
A custom hook is a JavaScript function whose name starts with use and that calls other hooks. Custom hooks let you extract and reuse stateful logic between components without changing the component hierarchy.
Custom hooks follow all the same rules as built-in hooks — they can only be called at the top level of components or other hooks.
// This is a custom hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
// Used in any component
function ResponsiveLayout() {
const width = useWindowWidth();
return <div>{width < 768 ? <MobileNav /> : <DesktopNav />}</div>;
}Why Extract Logic into Custom Hooks?
Before custom hooks, sharing stateful logic required patterns like render props or higher-order components — both added nesting and complexity. Custom hooks provide a clean, composable alternative:
- Reusability: The same logic can be used in any component.
- Separation of concerns: Components focus on rendering; hooks handle logic.
- Testability: Hooks can be tested independently using
renderHookfrom Testing Library. - Readability: Component bodies become declarative and easy to scan.
useFetch — Generic Data Fetching Hook
The generic useFetch hook handles loading, error, and data states for any URL:
interface Post {
id: number;
title: string;
body: string;
}
function PostList() {
const { data, loading, error, refetch } = useFetch<Post[]>(
'https://jsonplaceholder.typicode.com/posts?_limit=5'
);
if (loading) return <p>Loading...</p>;
if (error) return (
<div role="alert">
<p>Error: {error}</p>
<button type="button" onClick={refetch}>Retry</button>
</div>
);
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}The hook encapsulates all the useState and useEffect complexity, leaving the component clean and declarative.
useDebounce — Delaying Expensive Operations
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage — avoids API calls on every keystroke
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
const { data, loading } = useFetch<SearchResult[]>(
debouncedQuery
? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
: ''
);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <span>Searching...</span>}
{data?.map((result) => (
<p key={result.id}>{result.title}</p>
))}
</div>
);
}useToggle — Boolean State with Helper Functions
import { useState, useCallback } from 'react';
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse } as const;
}
function Modal() {
const { value: isOpen, toggle: toggleModal, setFalse: closeModal } = useToggle();
return (
<>
<button type="button" onClick={toggleModal}>
{isOpen ? 'Close' : 'Open'} modal
</button>
{isOpen && (
<dialog open aria-modal="true">
<h2>Modal Title</h2>
<p>Modal content goes here.</p>
<button type="button" onClick={closeModal}>Close</button>
</dialog>
)}
</>
);
}useMediaQuery — Responsive Logic in Hooks
import { useState, useEffect } from 'react';
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// Usage
function ThemeToggle() {
const prefersD = useMediaQuery('(prefers-color-scheme: dark)');
const isSmall = useMediaQuery('(max-width: 640px)');
return (
<div>
<p>System theme: {prefersD ? 'Dark' : 'Light'}</p>
<p>Viewport: {isSmall ? 'Mobile' : 'Desktop'}</p>
</div>
);
}useCounter — Numeric State with Constraints
import { useState, useCallback } from 'react';
interface UseCounterOptions {
min?: number;
max?: number;
step?: number;
}
function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
const { min = -Infinity, max = Infinity, step = 1 } = options;
const [count, setCount] = useState(
() => Math.min(Math.max(initialValue, min), max)
);
const increment = useCallback(() => {
setCount((c) => Math.min(c + step, max));
}, [step, max]);
const decrement = useCallback(() => {
setCount((c) => Math.max(c - step, min));
}, [step, min]);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
const set = useCallback((value: number) => {
setCount(Math.min(Math.max(value, min), max));
}, [min, max]);
return { count, increment, decrement, reset, set } as const;
}
// Usage
function QuantityPicker() {
const { count, increment, decrement, reset } = useCounter(1, { min: 1, max: 99 });
return (
<div className="quantity-picker">
<button type="button" onClick={decrement} disabled={count <= 1}>−</button>
<output>{count}</output>
<button type="button" onClick={increment} disabled={count >= 99}>+</button>
<button type="button" onClick={reset}>Reset</button>
</div>
);
}Composing Custom Hooks
Custom hooks can call other custom hooks, enabling composition of complex behaviors:
function useAuthenticatedFetch<T>(endpoint: string) {
const { token } = useAuth(); // custom hook for auth state
const url = token ? `/api${endpoint}` : '';
const result = useFetch<T>(url); // custom hook for fetching
return {
...result,
isAuthenticated: !!token,
};
}
function UserDashboard() {
const { data, loading, isAuthenticated } = useAuthenticatedFetch<DashboardData>('/dashboard');
if (!isAuthenticated) return <p>Please log in.</p>;
if (loading) return <p>Loading dashboard...</p>;
return <Dashboard data={data!} />;
}Hook Testing Pattern
Custom hooks can be tested with @testing-library/react's renderHook:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('respects max boundary', () => {
const { result } = renderHook(() => useCounter(9, { max: 10 }));
act(() => result.current.increment());
expect(result.current.count).toBe(10);
act(() => result.current.increment()); // cannot go above max
expect(result.current.count).toBe(10);
});
});In the next lesson, you will learn about the Context API — the built-in solution for sharing state across the component tree without prop drilling.
import { useState, useEffect, useCallback } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
interface UseFetchReturn<T> extends FetchState<T> {
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchReturn<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
const controller = new AbortController();
setState((prev) => ({ ...prev, loading: true, error: null }));
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ data, loading: false, error: null }))
.catch((err: unknown) => {
if (err instanceof Error && err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err.message });
}
});
return () => controller.abort();
}, [url, refreshKey]);
const refetch = useCallback(() => setRefreshKey((k) => k + 1), []);
return { ...state, refetch };
}
export { useFetch };
Sign in to track your progress