Custom Hooks
A custom hook is just a JavaScript function whose name starts with use and that calls one or more built-in hooks. Custom hooks let you lift repetitive stateful logic out of components and reuse it everywhere, without the wrapper-component nesting that older patterns like render props or higher-order components imposed. They are the idiomatic way to share behavior in modern React, keeping your components lean and focused on rendering.
What makes a function a hook
Two rules turn an ordinary function into a hook. First, its name must begin with use so that React’s linter and runtime can verify the Rules of Hooks are followed. Second, it must call at least one other hook (built-in or custom). A helper that just transforms data is a plain function, not a hook, and naming it use* would be misleading.
Custom hooks are subject to the same Rules of Hooks as components: call them only at the top level of a component or another hook, never inside conditions, loops, or nested functions.
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle, setOn];
}
Using it in a component reads almost like a built-in hook:
function Panel() {
const [open, toggleOpen] = useToggle();
return (
<div>
<button onClick={toggleOpen}>{open ? 'Hide' : 'Show'}</button>
{open && <p>Now you see me.</p>}
</div>
);
}
Hooks share logic, not state
A crucial point: every component that calls a custom hook gets its own independent state. The hook describes how state behaves, not a shared store. If two components each call useToggle(), toggling one does not affect the other. To share actual state, lift it up or use Context or a store consumed via useSyncExternalStore.
useLocalStorage
Persisting state to localStorage is a common need. This hook mirrors the useState API but syncs the value to storage on every change.
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage may be unavailable (private mode, quota) */
}
}, [key, value]);
return [value, setValue];
}
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Theme: {theme}
</button>
);
}
The lazy initializer reads storage only once on mount, and the effect writes back whenever value or key changes. Reloading the page restores the last saved theme.
useDebounce
Debouncing delays a value until updates stop arriving, which is ideal for search inputs. Note the cleanup function: it clears the pending timer whenever the value changes again before the delay elapses.
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (debouncedQuery) {
console.log('Searching for:', debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Output:
Searching for: react hooks
(Logged once, 400 ms after the user stops typing — not on every keystroke.)
useFetch
Data fetching combines state, an effect, and cleanup to avoid setting state after the component unmounts or the URL changes. An AbortController cancels the in-flight request.
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, error: null, loading: true });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ data, error: null, loading: false }))
.catch((error) => {
if (error.name !== 'AbortError') {
setState({ data: null, error, loading: false });
}
});
return () => controller.abort();
}, [url]);
return state;
}
function UserCard({ id }) {
const { data, error, loading } = useFetch(
`https://jsonplaceholder.typicode.com/users/${id}`
);
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error.message}</p>;
return <h2>{data.name}</h2>;
}
Tip: For production data fetching, prefer a dedicated library such as TanStack Query or SWR. They build on these same patterns but add caching, deduplication, and revalidation that a hand-rolled
useFetchlacks.
Naming and return shape
Choose return shapes that match how the hook is consumed. Return a tuple [value, setter] when the order is obvious (like useState), and an object { data, error, loading } when callers want to destructure by name. Keep handlers stable with useCallback so consumers can safely list them in dependency arrays.
| Return shape | Best for | Example |
|---|---|---|
| Array tuple | Two related values, positional | const [on, toggle] = useToggle() |
| Object | Several named fields | const { data, loading } = useFetch(url) |
| Single value | Derived/computed result | const debounced = useDebounce(q) |
Best Practices
- Prefix every hook with
useso the linter enforces the Rules of Hooks. - Extract a custom hook only when logic is genuinely reused or when it clarifies a component — premature abstraction adds indirection.
- Remember each call has independent state; use Context or a store for truly shared state.
- Memoize returned functions with
useCallbackand computed values with useMemo when consumers depend on referential stability. - Always clean up timers, subscriptions, and requests inside the hook’s effect to prevent leaks.
- Keep hooks focused on one concern; compose small hooks instead of building one giant hook.
- Accept configuration through arguments (delays, keys, URLs) so a hook stays reusable across contexts.