Building Custom Hooks to Share Logic
Learn to extract stateful, reusable logic out of components into custom hooks, and build several genuinely useful ones from scratch.
A custom hook is just a JavaScript function whose name starts with use and which calls one or more built-in hooks internally. That's the entire mechanism — but the payoff is large: any piece of stateful logic that would otherwise be copy-pasted across components (fetching data, syncing to localStorage, debouncing input, tracking online status) can be written once, tested once, and reused everywhere as a single function call.
What makes a function a custom hook
There is no special React API for 'defining a hook' — a custom hook is recognized purely by convention: its name starts with use, and it calls other hooks (useState, useEffect, useContext, or other custom hooks) in its body. That naming convention is what lets both the Rules of Hooks ESLint plugin and other developers reading your code understand that this function follows hook rules and carries hook-like behavior (its own persistent state, tied to whichever component calls it).
Each call to a custom hook from a different component gets its own independent state — calling useToggle() in three different components produces three completely separate booleans, not a shared one. This is easy to forget when a custom hook feels like it's 'sharing logic' — it shares the *implementation*, not the *instance* of state, unless you explicitly build sharing (e.g., via Context) into it.
Custom hooks can take arguments and return whatever shape is most useful for their callers — a single value, an array (like useState's [value, setter] pattern), or an object of named fields. Returning a named object is often more readable for hooks with several return values, since callers don't have to remember positional order.
The custom hook pattern: composing built-ins
The general shape is always the same: a custom hook wraps one or more built-in hooks (or browser APIs accessed through effects) and exposes a simpler, purpose-built interface. useFetch wraps useState and useEffect to hide the request lifecycle. useLocalStorage wraps useState and useEffect to hide reading from and writing to window.localStorage. useDebounce wraps useState and useEffect (with a timer) to hide the timing logic. In every case, the component using the hook doesn't need to know any of that internal wiring.
This is also how React itself encourages you to think about component logic: rather than writing one large component with a dozen useEffect calls handling unrelated concerns, extract each concern into its own named custom hook. useOnlineStatus(), useWindowSize(), and useAuth() called at the top of a component read far more clearly than the equivalent inlined effects, and each one can be tested and reused independently.
Below are eight commonly used custom hooks. useFetch, useLocalStorage, and useDebounce are implemented in full in the syntax and examples sections; the rest are described here so you know what each does and when to reach for it.
• useToggle — wraps a boolean useState with toggle(), setTrue(), and setFalse() helpers, useful for modals, accordions, and switches. • useMediaQuery(query) — subscribes to window.matchMedia(query) and returns a boolean that updates as the viewport crosses the breakpoint, useful for responsive logic in JS rather than CSS. • useOnlineStatus() — listens for the browser's online and offline window events and returns the current connectivity state, useful for showing a 'you're offline' banner. • usePrevious(value) — uses a useRef that's updated inside a useEffect after render, so it always holds the value from the previous render, useful for comparing 'what changed' between renders. • useForm(initialValues) — bundles form field values, change handlers, and validation state into one hook, reducing the boilerplate of controlled forms with many fields.
When to extract a custom hook
Extract logic into a custom hook when the same stateful pattern appears in two or more components, when a single component's effects and state are unrelated to each other and would read more clearly grouped by concern, or when you want to unit test a piece of logic without rendering a full component tree (a hook can be tested in isolation using a testing library's renderHook utility).
Do not extract a custom hook purely to reduce line count in a component that only uses the logic once and isn't likely to reuse it — an unnecessary layer of indirection can make code harder to follow, not easier. The right signal is repetition or a genuinely separable concern, not brevity for its own sake.
useFetch and useLocalStorage, fully implemented
useFetch's cancelled flag guards against calling setState after the component has unmounted or the url has changed and a newer request has started. useLocalStorage reads from storage lazily (inside the useState initializer function) so the read only happens once, on mount, not on every render. Returning as const from useLocalStorage gives callers a properly typed tuple, matching useState's own [value, setter] convention.
// useFetch: fetch data with loading/error state, typed by the caller
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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() as Promise<T>;
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true; // avoid setting state after unmount
};
}, [url]);
return { data, loading, error };
}
// useLocalStorage: state that persists across reloads
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = window.localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValu
…Code Examples
Every keystroke updates query immediately (so the input stays responsive), but debouncedQuery only updates 300ms after typing pauses, and useFetch only re-runs its request when that debounced value changes — combining two small custom hooks avoids firing a network request on every keystroke. Expected result: The input updates instantly on every keystroke; the network request fires only once, ~300ms after the user stops typing.
const debouncedQuery = useDebounce(query, 300);
useToggle wraps a single boolean useState with three memoized helper functions, so consuming components never write setState(s => !s) themselves — they just call toggle(). useCallback keeps toggle/setTrue/setFalse referentially stable across renders, which matters if they're passed down to memoized child components as props.
function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer); // cancel if value changes before the delay elapses
}, [value, delayMs]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data, loading } = useFetch<SearchResult[]>(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{loading ? <Spinner /> : <ResultsList results={data ?? []} />}
</div>
);
}function useToggle(initial = false) {
const [state, setState] = useState(initial);
const toggle = useCallback(() => setState((s) => !s), []);
const setTrue = useCallback(() => setState(true), []);
const setFalse = useCallback(() => setState(false), []);
return { state, toggle, setTrue, setFalse };
}
function Accordion() {
const { state: isOpen, toggle } = useToggle(false);
return (
<div>
<button onClick={toggle}>{isOpen ? 'Collapse' : 'Expand'}</button>
{isOpen && <p>Accordion content…</p>}
</div>
);
}How a custom hook composes built-in hooks
A custom hook is a thin, named layer over built-in hooks and browser APIs — the component only sees the simplified interface it returns.
Component
|
v
Custom Hook (useFetch, useDebounce, ...)
|
v
Built-in Hooks (useState, useEffect, useRef) / Browser APIs (fetch, localStorage, matchMedia)
|
v
Reusable Behavior returned to the ComponentImportant Rules
Name every custom hook starting with use so React's linting rules and other developers recognize it as a hook.
A custom hook must itself follow the Rules of Hooks — its internal hook calls must be unconditional and top-level.
Each call site gets independent state; a custom hook does not automatically share state between components unless it's built on top of Context or an external store.
Prefer returning a named object over a positional array/tuple once a hook has more than two or three return values, for readability at call sites.
Extract a custom hook when logic repeats or when it's a clearly separable concern — not merely to shorten a component that uses it once.
Where custom hooks earn their keep
Production codebases typically build a small internal library of custom hooks early on: useAuth() to expose the current user and login/logout functions, useFetch or a thin wrapper around a data-fetching library for consistent loading/error handling, and useMediaQuery for responsive behavior that CSS media queries can't express (like swapping entire component trees, not just styles). Centralizing these means a bug fix or improvement (e.g., adding request retries to useFetch) benefits every screen that uses it, instantly.
// Expecting shared state across components function useCounter() { const [count, setCount] = useState(0); return { count, increment: () => setCount((c) => c + 1) }; } // ComponentA and ComponentB each call useCounter() expecting the SAME count
// Lift the state up, or use Context, when state must be shared const CounterContext = createContext<{ count: number; increment: () => void } | undefined>(undefined); // Provide it once, consume with useContext in both ComponentA and ComponentB
A custom hook re-runs its useState from scratch for every component that calls it — it shares code, not state. Actual cross-component sharing requires lifting state up or using Context/an external store.
[multiple-choice] What is the defining convention that marks a function as a React custom hook? A. It returns JSX B. It is declared with the hook keyword C. Its name starts with use and it calls other hooks internally D. It is exported as the default export of its file
C. Its name starts with use and it calls other hooks internally — React and its tooling identify custom hooks purely by the use naming convention combined with calling other hooks — there is no separate hook-declaration syntax.
[true-false] True or false: if two different components each call the same custom hook, they automatically share the same state. A. True B. False
B. False — Each call to a custom hook creates its own independent state tied to the calling component instance. Sharing state across components requires lifting state up or using a mechanism like Context.
[multiple-choice] In the useDebounce example, what causes the debounced value to update? A. It updates immediately on every keystroke, same as the raw value B. A setTimeout scheduled in useEffect fires after the delay, and is cancelled/rescheduled whenever the input value changes before that C. It polls the input value every 300ms regardless of changes D. It only updates when the component unmounts
B. A setTimeout scheduled in useEffect fires after the delay, and is cancelled/rescheduled whenever the input value changes before that — Each time the tracked value changes, the effect's cleanup cancels the previous timer and a new one is scheduled — so the debounced value only updates once typing has paused for the full delay.
Implement usePrevious(value), a custom hook that returns the value a component had on the previous render (and undefined on the first render).
function usePrevious<T>(value: T): T | undefined { const ref = useRef<T>(); useEffect(() => { ref.current = value; }); return ref.current; }
Use a useRef to hold the previous value, and update it inside a useEffect — because effects run after render, the ref still holds the old value during the render itself.