How to Create a Custom Hook in React

Advanced12 min interview
Skills tested:
Extracting stateful logic from components into a custom hook with the use prefixReturning a named object instead of a tuple for extensibilityKnowing why the async function must be defined inside useEffect rather than making the callback asyncAdding AbortController cleanup to prevent race conditions when URL changesIdentifying that the hook has no error state and describing what fails silently without it

Advertisement

🧩 Scenario

Custom hooks are a core React interview topic. Beyond the basic pattern, interviewers probe the async function structure, the race condition that happens when URL changes rapidly, and the return shape design decision.

Architecture Walkthrough

Why the use Prefix Is a Contract, Not a Style Choice

The use prefix on a custom hook is not a naming convention — it is the signal React uses to enforce the Rules of Hooks. The ESLint react-hooks/rules-of-hooks plugin identifies custom hooks by this prefix and applies the same checks it applies to built-in hooks: the function must be called at the top level of a component or another custom hook, never inside a loop or condition. If you name your hook fetchData instead of useFetchData, the linter cannot enforce these rules, and calling it inside a conditional becomes a silent violation that produces inconsistent hook call counts between renders.

Structuring the Return Shape for Composability

A custom hook's return value should be a named object, not a tuple, except in specific cases like useState where position is the convention. Returning { data, isLoading, error } instead of [data, isLoading, error] means callers can destructure only what they need and rename properties: const { data: users, isLoading } = useFetch(url). Adding refetch or retry to the return object in the future is a non-breaking change — existing callers that do not destructure those properties are unaffected. A tuple would require either positional destructuring (fragile) or updating every call site.

The Cleanup Problem in Data Fetching Hooks

When the component unmounts or a dependency changes before the current fetch completes, the in-flight request should be cancelled to prevent setting state on an unmounted component. The cleanup function returned from useEffect runs when the component unmounts and also when the dependency changes before the next effect runs. Use an AbortController: pass { signal: controller.signal } to fetch and call controller.abort() in the cleanup. This cancels the fetch itself and prevents AbortError-triggered state updates from running. Without this, if url changes rapidly, a slower earlier response can overwrite a faster later one with stale data.


Key Code Explained

import { useEffect, useState } from 'react';

interface FetchState<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

// Full useFetch with loading, error, data, and AbortController cleanup
export function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Reset state when URL changes — prevents stale data flash
    setData(null);
    setIsLoading(true);
    setError(null);

    const controller = new AbortController();

    // async function defined INSIDE the effect — not async callback
    // useEffect callback must return a cleanup function or undefined
    // An async callback returns a Promise — React cannot use it as cleanup
    async function fetchData() {
      try {
        const res = await fetch(url, { signal: controller.signal });

        // Treat non-2xx as errors — fetch only rejects on network failure
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        }

        const json: T = await res.json();
        setData(json);
      } catch (err) {
        // AbortError fires when controller.abort() is called — ignore it
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchData();

    // Cleanup: abort in-flight request when URL changes or component unmounts
    return () => controller.abort();
  }, [url]);

  return { data, isLoading, error };
}


// Clean component — all logic in the hook
interface User {
  id: number;
  name: string;
  email: string;
}

function UserCard({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useFetch<User>(
    `/api/users/${userId}`
  );

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}


// Another common custom hook: useDebounce
function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debouncedValue;
}

// Usage: debounce a search query before fetching
function SearchResults() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  const { data, isLoading } = useFetch<Result[]>(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  );

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {isLoading && <Spinner />}
      {data?.map((r) => <ResultItem key={r.id} result={r} />)}
    </div>
  );
}

The early return for isLoading is a guard clause — it prevents data.map from running when data is still null, which would throw a runtime error. Using optional chaining data?.map instead would silently render nothing rather than showing a loading state, making an empty screen indistinguishable from a genuine empty result.


Tradeoffs

ApproachDeduplicationCachingBackground refetchCode size
Custom hook with local stateNoNoNoLess
Custom hook with AbortControllerNoNoNoModerate
React Query / SWRYesYesYesLess (per callsite)

What Interviewers Actually Check

  • Whether you can name and explain the use prefix rule beyond "it's a convention"
  • Whether you define the async function inside useEffect and can explain why the callback cannot be async
  • Whether you add AbortController cleanup and can describe the race condition it prevents
  • Whether you return a named object and can explain why a tuple is worse for growing APIs
  • Whether you notice the missing error state in the basic pattern and describe exactly what fails without it

Follow-Up Questions

  1. Add an error state to useFetch so that network failures and non-2xx responses are both surfaced to the caller. What does the return shape look like, and how do you handle the two failure modes differently?
  2. The URL passed to useFetch includes a query string that changes on every keystroke. Describe the exact problem this causes and implement a debounce strategy inside or around the hook.
  3. Two components on the same page call useFetch('/api/user/profile') simultaneously. How many network requests are made, and what would you add to collapse them into one?
  4. A coworker wraps useFetch in a useCallback before passing the result URL. Is this correct? What misunderstanding does it reveal?
  5. You are asked to add request caching so that navigating back to a page does not refetch data the user already loaded. How do you implement this inside useFetch without pulling in a library?

Common Candidate Mistakes

  • Naming the function fetchData without the use prefix — the linter treats it as a regular function and will not enforce Rules of Hooks
  • Making the useEffect callback async — it returns a Promise, React interprets it as a cleanup function and ignores it, logging a warning
  • Returning [data, isLoading] as a tuple — adding a third return value is a positional breaking change for all callers
  • Not including AbortController — the component receives stale data when the URL changes before the previous fetch resolves
  • The basic useFetch pattern has no error state: unhandled rejections from fetch or res.json() are swallowed, and the UI stays in a loading state forever

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain why the use prefix is required and what breaks without it?
  • Can you implement useFetch with loading, error, and data state?
  • Can you explain why the useEffect callback cannot be async?
  • Can you add AbortController cleanup and explain the race condition it prevents?
  • Can you explain why returning a named object is preferable to a tuple for a hook that will grow?

Summary

Custom hooks extract stateful logic into reusable functions that any component can call without duplicating that logic. The use prefix enables Rules of Hooks linting and is non-negotiable. Returning a named object rather than a tuple keeps the API non-breaking as the hook adds new return values. For data fetching, define an async function inside the effect body and call it immediately — the useEffect callback cannot be async because React cannot use a Promise as a cleanup return. Add AbortController with cleanup to cancel in-flight requests when the URL changes or the component unmounts, preventing race conditions and stale data. Always add an error state: without it, network failures are swallowed silently and the UI stays in a perpetual loading state. When fetching complexity grows beyond a single component, React Query or SWR are worth reaching for — they provide caching, deduplication, background refetching, and retry logic that manual useFetch implementations do not.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

When should I create a custom hook?

When the same combination of useState, useEffect, or other hooks appears in more than one component. Common examples: data fetching with loading/error states, form field state with validation, subscribing to a browser API (resize, online/offline, geolocation), and debouncing a value.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement