Topics in this subject
React 6 min read Updated 6 Aug 2026

Custom Hooks

Custom hooks extract reusable stateful logic; they share logic not state—each call gets isolated state.

🧑‍🏫 Sabse pehle — simple mein samjho#

Kabhi ek hi logic (jaise ek on/off toggle) kai components mein baar-baar likhni padti hai. Custom hook = apna khud ka use... function bana lo jismein wo repeat hone waali logic daal do, phir kai components use kar sakein. Ye ek reusable recipe jaisi hai — recipe sabke paas same, par har ghar apni alag sabzi banata hai (matlab state alag rehti hai, share nahi hoti).

// apna hook — naam "use" se shuru hona chahiye
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = () => setOn(v => !v);
  return [on, toggle];        // jo chahiye wo return kar do
}

function LightSwitch() {
  const [on, toggle] = useToggle();   // reuse!
  return <button onClick={toggle}>{on ? "ON" : "OFF"}</button>;
}

Yaad rakho: Custom hook = repeat logic ki reusable recipe — logic share hoti hai, state nahi.

A custom hook is a function that calls other hooks to package reusable stateful logic. It's the primary mechanism for reuse in modern React—replacing the old HOC and render-prop patterns for logic sharing.

The rules 🎯#

  • Name must start with use. This is how the linter and React know to apply the Rules of Hooks (call hooks unconditionally, at the top level only).
  • A custom hook may call useState, useEffect, useRef, other custom hooks—anything a component can.
  • Return whatever shape is ergonomic: a tuple [value, setter] (like useState), or an object { data, error, loading } for many named fields.

Logic is shared, state is NOT 🎯#

Each call to a custom hook gets its own isolated state. Two components (or two calls in one component) using useToggle do not share a value—the hook shares the recipe, not the data.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(o => !o), []);
  return [on, toggle];
}

function Panel() {
  const [open, toggleOpen] = useToggle();     // independent state
  const [muted, toggleMuted] = useToggle(true); // independent state
  // …
}
flowchart TD
    H["useToggle (logic recipe)"] --> C1["Call in Panel: open state"]
    H --> C2["Call in Panel: muted state"]
    H --> C3["Call in Sidebar: collapsed state"]
    C1 --- N1["isolated"]
    C2 --- N2["isolated"]
    C3 --- N3["isolated"]

Real-world example: useOrderStatus (Swiggy/Zomato-style order tracking)#

A concrete instance of "logic shared, state not shared": wrap useState + useEffect (polling) into useOrderStatus(orderId), then use it from two unrelated screens — a full order-tracking page and a small order-summary widget elsewhere in the app (e.g. a sticky header chip). Each screen gets its own independent poll and state, but the fetch/poll logic is written once.

function useOrderStatus(orderId) {
  const [status, setStatus] = useState("placed"); // placed → preparing → out_for_delivery → delivered
  const [eta, setEta] = useState(null);

  useEffect(() => {
    let ignore = false;
    const poll = async () => {
      const res = await fetch(`/api/orders/${orderId}/status`);
      const data = await res.json();
      if (!ignore) {
        setStatus(data.status);
        setEta(data.etaMinutes);
      }
    };
    poll();
    const id = setInterval(poll, 5000); // poll every 5s while the order is active
    return () => { ignore = true; clearInterval(id); };
  }, [orderId]);

  return { status, eta };
}

// Screen 1: full order-tracking page
function OrderTrackingPage({ orderId }) {
  const { status, eta } = useOrderStatus(orderId); // its own instance
  return (
    <div>
      <h1>Order #{orderId}</h1>
      <p>Status: {status} {eta && `· ETA ${eta} min`}</p>
    </div>
  );
}

// Screen 2: a small widget elsewhere in the app (e.g. sticky header)
function OrderSummaryWidget({ orderId }) {
  const { status } = useOrderStatus(orderId); // a second, independent instance
  return <span className="chip">{status}</span>;
}

Both OrderTrackingPage and OrderSummaryWidget call the same hook with the same orderId, but each mounts its own useState/useEffect pair and fires its own polling interval — updating one never touches the other's state.

flowchart TD
  H["useOrderStatus(orderId) - the recipe"] -.composes.-> S["useState(status, eta)"]
  H -.composes.-> E["useEffect: poll every 5s, cleanup clears interval"]

  H --> T["Called in OrderTrackingPage"]
  H --> W["Called in OrderSummaryWidget"]

  T --> TS["Own status/eta state plus own interval"]
  W --> WS["Own status/eta state plus own interval"]

  TS -.independent of.- WS

Canonical examples#

useLocalStorage — state synced to localStorage:

function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    const raw = localStorage.getItem(key);
    return raw ? JSON.parse(raw) : initial;
  });
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue];
}

useDebounce — a value that lags behind rapid changes:

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // ⚠️ cleanup cancels the pending timer
  }, [value, delay]);
  return debounced;
}

useFetch — data fetching with abort:

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(r => r.json())
      .then(data => setState({ data, error: null, loading: false }))
      .catch(error => {
        if (error.name !== "AbortError") setState({ data: null, error, loading: false });
      });
    return () => controller.abort(); // ⚠️ cancel on unmount / url change to avoid races
  }, [url]);
  return state;
}

🟢 In production prefer TanStack Query / SWR over hand-rolled useFetch—they add caching, dedup, retries, and revalidation. useFetch is great for interviews and small apps.

Composition pattern 🟢#

Custom hooks compose—build small hooks and combine them into task-specific ones. This keeps each hook single-purpose and testable.

function useDebouncedSearch(query) {
  const debounced = useDebounce(query, 400);   // compose useDebounce
  return useFetch(`/api/search?q=${debounced}`); // …with useFetch
}

⚠️ Return stable references where consumers may put them in dependency arrays—wrap returned callbacks in useCallback and derived objects in useMemo, or the hook's output changes every render and defeats downstream memoization.

Interview Q&A#

Q1. What makes something a custom hook rather than a regular function? It calls React hooks and its name starts with use, which lets React and the linter enforce the Rules of Hooks. A plain helper that calls no hooks doesn't need the prefix.

Q2. If two components use the same custom hook, do they share state? No. A custom hook shares logic, not state—each call creates its own independent state, effects, and refs. They're as isolated as if you inlined the code.

Q3. Why must a hook's name start with use? So React's linting (Rules of Hooks) can verify hooks are called unconditionally at the top level. The convention is how tooling distinguishes hook calls from ordinary function calls.

Q4. Why wrap returned callbacks from a custom hook in useCallback? Consumers often place returned values in dependency arrays or pass them to React.memo children. Stable references prevent unnecessary effect re-runs and re-renders downstream.

Q5. How did custom hooks replace HOCs and render props? They share stateful logic without adding wrapper components to the tree, avoiding "wrapper hell" and prop-name collisions. Logic composes as plain function calls instead of nested components.