React Interview Prep
Hooks

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 useLocalStorage that syncs state with the browser. A useFetch that handles loading, error, and data in three lines. A useDebounce that 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


Custom Hooks thumbnail


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

  1. 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 with use, the linter checks it. If it does not, the linter ignores it, and bugs slip through.

  2. Custom hooks can call other hooksuseState, useEffect, useRef, useCallback, or even other custom hooks. This is what separates them from regular functions.

  3. 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.

  4. 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.

Custom Hooks visual 1


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)

Custom Hooks visual 2


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 renderHook to call the hook in an isolated test component
  • Use act() to wrap state updates so React processes them
  • Use rerender to simulate prop changes
  • Use fake timers for hooks that involve setTimeout or setInterval

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 use and that calls other React hooks inside it (useState, useEffect, useRef, etc.). A regular utility function like formatDate() 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. The use prefix 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 ComponentA and ComponentB both call useCounter(), they each have their own count variable. 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 use prefix 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 with use, 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 renderHook from @testing-library/react to call the hook in isolation. Use jest.useFakeTimers() to control time. After triggering a state change with rerender, assert that the value has not changed yet, then call act(() => jest.advanceTimersByTime(delay)) to fast-forward past the timeout, and assert the value has updated. Always call jest.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.

On this page