What are custom hooks in React
Custom hooks are JavaScript functions that start with "use" and call other React hooks to share stateful logic across components.
Theory
TL;DR
- Think of a custom hook like a shared recipe: write the
useState+useEffectlogic once, any component grabs it without rewriting - Main difference from a regular function: custom hooks run during React's render phase, so state and effects stay synced with re-renders
- Decision rule: if the same hook logic appears in 2+ components or grows past ~20 lines, extract it
- If a function doesn't call any hooks, it's just a function. No "use" prefix needed.
Quick example
// Before: resize logic duplicated in every component that needs window width
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <div>Width: {width}px</div>;
}
// After: extract once, reuse anywhere
function useWindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // cleanup on unmount
}, []);
return width;
}
function WindowSize() {
const width = useWindowSize(); // component stays clean
return <div>Width: {width}px</div>;
}That is the whole pattern. The hook moves out, the component gets simpler.
How custom hooks differ from regular functions
A regular function can be called anywhere: inside loops, conditionals, callbacks. A custom hook cannot. React tracks hook calls in a fixed order inside each component's fiber node via a linked list of states and effects. Call hooks conditionally and that list shifts, so React reads the wrong state from the wrong slot.
This is why the "use" prefix matters. React sees it and enforces the Rules of Hooks: call at the top level only, and only from components or other hooks. A function without "use" gets no such treatment and its internal state won't persist between renders.
When to use
- API fetch + loading/error state repeated in multiple components: custom hook
- Form validation and submission logic shared across pages: custom hook
- Syncing state with localStorage: custom hook
- A one-off date formatter with no state: plain function
- A UI animation used in exactly one component: keep it inline
How React handles it internally
React processes custom hooks during the render phase using the same mechanism as built-in hooks. Each hook call maps to a slot in the fiber's linked list through mountState and updateState dispatchers. On re-render, React replays calls in the same order and restores state from those slots. There is no V8 magic here. It is React's reconciler enforcing order. That is also why composition works freely: useUser can call useFetch, which calls useState and useEffect. All of them share the same component fiber.
Common mistakes
Forgetting the "use" prefix:
function getWindowSize() { // React skips hook rules here
const [width, setWidth] = useState(window.innerWidth); // state won't persist
}Without the prefix React treats this as a plain function. State tracking is skipped entirely. Fix: rename to useWindowSize.
Calling a hook conditionally:
function Component({ show }) {
if (show) {
const [data, setData] = useState(null); // hook order shifts when show changes
}
}This causes "Invalid hook call" or unpredictable state bugs. Move every hook call to the top level of the function, always.
Stale closure in a fetch hook:
function useAsyncTask(callback) {
const [result, setResult] = useState(null);
useEffect(() => {
callback().then(setResult);
}, [callback]); // callback must be stable or this fires every render
return result;
}
// Wrong: callback recreated each render, triggers infinite loop
function BadComponent() {
const [count, setCount] = useState(0);
const result = useAsyncTask(async () => {
return count; // captures stale count from first render only
});
}
// Fix: stabilize with useCallback
const callback = useCallback(async () => {
return count;
}, [count]);
const result = useAsyncTask(callback);This is the most common production issue with custom hooks. ESLint's react-hooks/exhaustive-deps catches it automatically.
Wrapping trivial logic in a hook:
function useDouble(n) { return n * 2; } // no hooks inside, no pointIf there are no hook calls inside, it is just a function. Skip the "use" prefix and the extra indirection.
Real-world usage
- TanStack Query:
useQuerycomposesuseEffect+useStatefor fetching with caching - React Hook Form:
useFormpackages all form state and validation into one call - SWR (used in Next.js):
useSWRhandles fetch, caching, and revalidation - Zustand:
useStoreacts as a lightweight hook for global state
Custom hooks replaced higher-order components and render props for sharing stateful logic. Libraries like TanStack Query show how far the pattern can scale.
Follow-up questions
Q: Why must custom hook names start with "use"?
A: React scans for the "use" prefix during render to apply the Rules of Hooks. Without it, the function is treated as a regular function, state doesn't persist between renders, and effects won't run correctly.
Q: Can a custom hook call other custom hooks?
A: Yes. They compose freely. useUser can call useFetch, which calls useState and useEffect. All of them share the same component's fiber node.
Q: What happens if you call a custom hook inside a callback?
A: React throws "Invalid hook call". Hooks must be called at the top level of a function component or another hook, not inside event handlers or async functions.
Q: What is the difference between a custom hook and useReducer?
A: useReducer manages complex local state inside one component. A custom hook packages state logic so multiple components can share it without duplicating code.
Q: Explain a stale closure bug in a custom fetch hook.
A: If the useEffect dependency array omits a prop like userId, the effect captures the value from the first render. When the component receives a new userId, the effect does not re-run and you get data for the wrong user. Fix: include all dependencies in the array, or stabilize the callback reference with useCallback.
Examples
Basic: tracking window width
function useWindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize); // cleanup on unmount
}, []);
return width;
}
function Header() {
const width = useWindowSize();
return <nav>{width < 768 ? <MobileMenu /> : <DesktopMenu />}</nav>;
}One hook, many components. Any component that needs window width calls useWindowSize() instead of re-implementing the listener logic from scratch.
Intermediate: data fetching with loading and error states
// useUser.js - shared across dashboards, profile pages, admin panels
function useUser(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 => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]); // re-fetches automatically when userId changes
return { user, loading, error };
}
function Profile({ userId }) {
const { user, loading, error } = useUser(userId);
if (loading) return <div>Loading...</div>;
if (error) return <div>Failed to load user</div>;
return <div>{user.name}</div>;
}The component only handles rendering. All fetch logic lives in the hook. I've seen the missing [userId] dep cause hours of debugging in production where switching between users showed stale data - that one dependency array entry fixes it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.