Custom Hooks
Reusable Logic
LinkedIn Hook
Every React codebase I have worked on that was easy to maintain had one thing in common: custom hooks everywhere.
Not clever abstractions. Not over-engineered utilities. Just clean, focused hooks that extract repeated logic into one place. A
useLocalStoragethat syncs state with the browser. AuseFetchthat handles loading, error, and data in three lines. AuseDebouncethat prevents your search input from firing 30 API calls per second.In interviews, custom hooks reveal whether you truly understand how React hooks compose — or whether you just memorize the built-in ones. Interviewers ask you to write one on the spot. They want to see if you know the naming rule, how state flows through a custom hook, and why it is not the same as a utility function.
In this lesson, I cover: the rules for building custom hooks, how to extract stateful logic from components, and four real-world hooks you can code in an interview — useLocalStorage, useFetch, useDebounce, and useOnClickOutside. Plus how to test them.
If you have ever copy-pasted the same useState + useEffect pattern across three components — this lesson is for you.
Read the full lesson → [link]
#React #JavaScript #InterviewPrep #Frontend #CodingInterview #ReactHooks #CustomHooks #100DaysOfCode
What You'll Learn
- What custom hooks are and why they exist — extracting stateful logic from components
- The naming rule — why custom hooks must start with
use - How custom hooks share stateful logic without sharing state itself
- Four practical custom hooks you can build in an interview:
useLocalStorage,useFetch,useDebounce,useOnClickOutside - How to test custom hooks properly
The Concept — Custom Hooks as Reusable Recipes
Analogy: The Master Chef's Recipe Cards
Imagine you work in a large restaurant kitchen with ten chefs. Every chef needs to make garlic butter — the same steps every time: melt butter, mince garlic, combine, season.
At first, each chef memorizes the steps and does them inline. But one day, the head chef writes the process on a recipe card and pins it to the wall. Now every chef follows the same card. If the recipe improves, you update one card, and every dish benefits.
Custom hooks are recipe cards. They extract a repeatable process (stateful logic) into a single function that any component can follow.
But here is the critical part: each chef who follows the recipe card gets their own pot of garlic butter. They do not share the same pot. The recipe is shared. The result is independent.
This is exactly how custom hooks work. When two components call useLocalStorage("theme"), they each get their own state. The logic (read from localStorage, sync on change) is shared. The state is not.
A custom hook is not a utility function like formatDate(). A utility function has no state, no effects, no connection to React's rendering cycle. A custom hook calls other hooks inside it — that is what makes it a hook.
The Rules of Custom Hooks
-
The name must start with
use— This is not just a convention. React's linter uses this prefix to enforce the Rules of Hooks (no conditional calls, no calls inside loops). If your function starts withuse, the linter checks it. If it does not, the linter ignores it, and bugs slip through. -
Custom hooks can call other hooks —
useState,useEffect,useRef,useCallback, or even other custom hooks. This is what separates them from regular functions. -
Custom hooks do not share state between components — Each component that calls a custom hook gets its own isolated copy of that hook's state. The logic is reused. The state is fresh.
-
Custom hooks can return anything — A single value, an array, an object, or nothing at all. The return signature is your API design choice.
Code Example 1: useLocalStorage
This hook syncs a piece of state with localStorage so it persists across page reloads.
import { useState, useEffect } from "react";
// Custom hook: useState that persists to localStorage
function useLocalStorage(key, initialValue) {
// Lazy initialization: read from localStorage on first render only
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
// If a stored value exists, parse and use it; otherwise, use initialValue
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
// If parsing fails (corrupted data), fall back to initialValue
return initialValue;
}
});
// Sync state changes to localStorage
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage might be full or blocked — fail silently
console.warn("Could not write to localStorage");
}
}, [key, value]);
return [value, setValue];
}
// Usage — works exactly like useState, but persists
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage("theme", "dark");
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Current theme: {theme}
</button>
);
}
// Output on first visit: Current theme: dark
// After clicking: Current theme: light
// After refreshing the page: Current theme: light (persisted!)
Why interviewers love this: It tests lazy initialization, useEffect dependencies, error handling, and the ability to create a clean API that mirrors useState.
Code Example 2: useFetch
A hook that handles the three states of any data fetch: loading, error, and data.
import { useState, useEffect } from "react";
// Custom hook: fetches data from a URL and manages loading/error states
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset states when URL changes
setData(null);
setLoading(true);
setError(null);
// AbortController lets us cancel the fetch if the component unmounts
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
// Ignore abort errors — they happen on cleanup, not real failures
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchData();
// Cleanup: cancel the request if URL changes or component unmounts
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage — three lines replace a dozen
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h1>{data.name}</h1>;
}
// Output while fetching: Loading...
// Output on success: Alice
// Output on failure: Error: HTTP error: 404
Why this matters: Without this hook, every component that fetches data repeats the same useState + useEffect + loading + error pattern. With it, the component only handles rendering.
Code Example 3: useDebounce
Delays updating a value until a specified time has passed since the last change. Essential for search inputs.
import { useState, useEffect } from "react";
// Custom hook: returns a debounced version of the input value
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set a timer to update the debounced value after the delay
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cleanup: if value changes before delay expires, cancel the old timer
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Usage — search input that waits 500ms before triggering an API call
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
const { data, loading } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <p>Searching...</p>}
{data && data.map((item) => <p key={item.id}>{item.title}</p>)}
</div>
);
}
// User types "react hooks" quickly:
// Without debounce: 11 API calls (one per keystroke)
// With debounce (500ms): 1 API call (fires 500ms after the user stops typing)
Code Example 4: useOnClickOutside
Detects clicks outside a referenced element. Perfect for closing dropdown menus and modals.
import { useEffect } from "react";
// Custom hook: calls handler when a click happens outside the ref element
function useOnClickOutside(ref, handler) {
useEffect(() => {
function listener(event) {
// Do nothing if the click is inside the referenced element
if (!ref.current || ref.current.contains(event.target)) {
return;
}
// Click was outside — call the handler
handler(event);
}
// Listen for both mouse and touch events
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
// Cleanup: remove listeners when the component unmounts or dependencies change
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref, handler]);
}
// Usage — dropdown that closes when you click outside
function Dropdown() {
const [open, setOpen] = useState(false);
const dropdownRef = useRef(null);
// Close the dropdown when clicking outside
useOnClickOutside(dropdownRef, () => setOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && (
<ul>
<li>Profile</li>
<li>Settings</li>
<li>Logout</li>
</ul>
)}
</div>
);
}
// Click "Menu": dropdown opens showing Profile, Settings, Logout
// Click anywhere outside the dropdown: dropdown closes
// Click inside the dropdown: dropdown stays open
Testing Custom Hooks
Custom hooks cannot be called outside of a React component. To test them in isolation, use the renderHook utility from @testing-library/react.
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";
// Test: useDebounce returns the initial value immediately
test("returns initial value before delay", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
// Test: useDebounce updates after the delay expires
test("updates value after delay", () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
// Change the input value
rerender({ value: "world", delay: 500 });
// Before delay: still the old value
expect(result.current).toBe("hello");
// Fast-forward time by 500ms
act(() => jest.advanceTimersByTime(500));
// After delay: updated to new value
expect(result.current).toBe("world");
jest.useRealTimers();
});
Key testing principles:
- Use
renderHookto call the hook in an isolated test component - Use
act()to wrap state updates so React processes them - Use
rerenderto simulate prop changes - Use fake timers for hooks that involve
setTimeoutorsetInterval
Common Mistakes
Mistake 1: Not starting the name with "use"
// WRONG — React's linter won't enforce hook rules inside this function
function getLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
return JSON.parse(localStorage.getItem(key)) ?? initialValue;
});
// This will work at runtime, but the linter cannot check it
// Conditional calls and loop calls will silently break
return [value, setValue];
}
// RIGHT — "use" prefix enables linter enforcement
function useLocalStorage(key, initialValue) {
// Same code, but now the linter protects you
}
The use prefix is a contract with React. Without it, you lose static analysis, and bugs become invisible until production.
Mistake 2: Thinking custom hooks share state between components
function useCounter() {
const [count, setCount] = useState(0);
return { count, increment: () => setCount((c) => c + 1) };
}
function ComponentA() {
const { count, increment } = useCounter();
// This count is INDEPENDENT from ComponentB's count
return <button onClick={increment}>A: {count}</button>;
}
function ComponentB() {
const { count, increment } = useCounter();
// This count is INDEPENDENT from ComponentA's count
return <button onClick={increment}>B: {count}</button>;
}
// Clicking A's button: A: 1, B: 0
// Clicking B's button: A: 1, B: 1
// They do NOT share state — each call creates fresh state
If you need shared state, you need Context, a state management library, or lifting state up. Custom hooks share logic, not state.
Mistake 3: Missing cleanup in custom hooks that add event listeners or timers
// WRONG — event listener is never removed, causes memory leaks
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
// Missing return cleanup!
}, []);
return width;
}
// RIGHT — cleanup removes the listener on unmount
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
Every addEventListener, setTimeout, setInterval, or subscription inside a custom hook must have a corresponding cleanup. This is an interview red flag if missed.
Interview Questions
Q: What is a custom hook, and how is it different from a regular utility function?
A custom hook is a JavaScript function whose name starts with
useand that calls other React hooks inside it (useState, useEffect, useRef, etc.). A regular utility function likeformatDate()has no connection to React's rendering system — it takes input, returns output, with no state or side effects. A custom hook participates in React's lifecycle: it can hold state, trigger re-renders, run effects, and clean up on unmount. Theuseprefix tells React's linter to enforce the Rules of Hooks inside it.
Q: Do two components calling the same custom hook share state?
No. Each component that calls a custom hook gets its own independent copy of that hook's state. Custom hooks share logic — the instructions for how to manage state — but not the state itself. If
ComponentAandComponentBboth calluseCounter(), they each have their owncountvariable. To share actual state between components, you need React Context, a state management library, or to lift state up to a common parent.
Q: Write a useLocalStorage hook that works like useState but persists to localStorage.
The hook uses lazy initialization to read from localStorage on first render, returns a
[value, setValue]tuple, and uses a useEffect to write changes back to localStorage whenever the value or key changes. Error handling wraps both JSON.parse (for corrupted data) and localStorage.setItem (for storage limits). See Code Example 1 above for the full implementation.
Q: Why must custom hooks start with "use"? What breaks if they don't?
The
useprefix is required for React's ESLint plugin (eslint-plugin-react-hooks) to recognize the function as a hook and enforce the Rules of Hooks — no conditional calls, no calls inside loops, only call at the top level of a component or another hook. If your function does not start withuse, the linter treats it as a regular function and skips these checks. Your code might work at first, but you lose the safety net that catches hook ordering bugs before they reach production.
Q: How would you test a custom hook that uses setTimeout internally?
Use
renderHookfrom@testing-library/reactto call the hook in isolation. Usejest.useFakeTimers()to control time. After triggering a state change withrerender, assert that the value has not changed yet, then callact(() => jest.advanceTimersByTime(delay))to fast-forward past the timeout, and assert the value has updated. Always calljest.useRealTimers()in cleanup to avoid affecting other tests.
Quick Reference — Cheat Sheet
+-----------------------------------+-------------------------------------------+
| Concept | Key Point |
+-----------------------------------+-------------------------------------------+
| What is a custom hook? | A function starting with "use" that |
| | calls other hooks. Extracts reusable |
| | stateful logic from components. |
+-----------------------------------+-------------------------------------------+
| Naming rule | MUST start with "use" — enables linter |
| | enforcement of Rules of Hooks. |
+-----------------------------------+-------------------------------------------+
| State sharing | Custom hooks share LOGIC, not STATE. |
| | Each component gets its own state copy. |
+-----------------------------------+-------------------------------------------+
| useLocalStorage(key, init) | useState + useEffect that syncs to |
| | localStorage. Lazy init reads on mount. |
+-----------------------------------+-------------------------------------------+
| useFetch(url) | Returns { data, loading, error }. |
| | Handles AbortController for cleanup. |
+-----------------------------------+-------------------------------------------+
| useDebounce(value, delay) | Returns debounced value. Resets timer on |
| | every change, fires after delay passes. |
+-----------------------------------+-------------------------------------------+
| useOnClickOutside(ref, handler) | Calls handler on clicks outside ref. |
| | Listens for mousedown and touchstart. |
+-----------------------------------+-------------------------------------------+
| Testing custom hooks | Use renderHook() from testing-library. |
| | Use act() for state updates. |
| | Use fake timers for async hooks. |
+-----------------------------------+-------------------------------------------+
RULE: Name starts with "use" — no exceptions.
RULE: Always clean up listeners, timers, and subscriptions.
RULE: Custom hooks share logic, never state.
Previous: Lesson 3.6 — useReducer — Complex State Logic → Next: Lesson 4.1 — Component Lifecycle with Hooks →
This is Lesson 3.7 of the React Interview Prep Course — 10 chapters, 42 lessons.