Topic #21Core15 min read

Custom Hooks

How to extract reusable stateful logic into use-prefixed functions that compose the built-in hooks — what a custom hook shares (logic, not state), how to shape its return value, and the patterns for building fetch, toggle, debounce, and subscription hooks.

#react#hooks#custom-hooks#reusability#composition#usefetch#debounce#testing

What a custom hook is (start here). A custom hook is just a JavaScript function whose name starts with use and which calls one or more other hooks inside it. That's the entire definition — there is no special API. It packages up stateful logic (useState + useEffect + refs, etc.) so multiple components can reuse it with a single call. It's the React equivalent of extracting a reusable service or utility in Java: pull shared behaviour out of the components and give it a name.

It shares logic, not state (the crucial distinction). Two components that call the same custom hook do not share a state value — each call creates its own independent copy of every useState/useReducer/useRef inside. useToggle() in component A and useToggle() in component B are two separate booleans. If you need components to share the same value, you need a shared store or React Context — a custom hook alone only reuses the shape of the logic, not its data.

Why the use prefix is mandatory. The prefix is how React's linter recognizes a function as a hook and applies the Rules of Hooks (top-level only) and exhaustive-deps to its body. Without it, ESLint won't verify that the hooks inside are called correctly, and readers won't know the function is stateful. Because a custom hook calls hooks, it inherits both Rules of Hooks: call it at the top level of a component or another hook, never conditionally.

Choosing the return shape. Return whatever is ergonomic. A tuple [value, setValue] as const mirrors useState and reads well when there are one or two positional values the caller will rename freely (const [open, toggle] = useToggle()). An object { items, loading, error } is clearer when there are several values, because callers destructure by name and order doesn't matter. Use as const on tuples so TypeScript infers a fixed-length tuple with precise element types instead of a loose array.

Stabilize what you return when identity matters. If your hook returns functions or objects that consumers will pass to React.memo children or list in dependency arrays, wrap them in useCallback/useMemo so they keep a stable reference across renders. A toggle recreated every render would defeat a memoized child or re-run a consumer's effect. For a trivial local useToggle it rarely matters, but for a widely-reused hook it's good hygiene.

The canonical data-fetching hook. useFetch(url) pairs useState (data/loading/error) with useEffect (the request + cleanup), including the cancelled / AbortController race guard and re-fetching when the URL changes. Every component that needs that URL's data calls one line and gets back { data, loading, error }. This is exactly the logic that libraries like React Query and SWR generalize with caching and dedup — a custom hook is how you'd hand-roll the same encapsulation.

Composition — hooks calling hooks. Because a custom hook is an ordinary function, it can call other custom hooks. useUserExpenses(userId) might call useFetch internally; useAuthedFetch might call useAuth (context) then useFetch. This composition is the payoff: you build small, focused hooks and assemble them, the same way you compose functions or services on the backend.

When to extract one. Extract a custom hook when the same stateful pattern — fetching, a toggle, a subscription, a form field, debouncing, media queries, local-storage sync — appears in more than one component, or when a single component's inline hook logic has grown large enough that naming and isolating it improves readability and testability. Don't extract prematurely; wait until you see the duplication or the complexity.

Testing and gotchas. Custom hooks are testable with React Testing Library's renderHook, so extracting logic also makes it unit-testable in isolation. Gotchas: a hook must obey the Rules of Hooks like any component (no conditional calls); returning a new object/array literal each render can cause consumers' effects to re-run (memoize if needed); and remember a hook shares behaviour, not a singleton — reaching for a hook when you actually need shared global state is a common mistake.

The mental model (memorise this). A custom hook is a named, composable bundle of hook calls: it reuses logic, and every caller gets its own private state. Prefix it use, obey the Rules of Hooks inside it, return a tuple (like useState) for a value or two and an object for several, stabilize returned functions/objects with useCallback/useMemo when identity matters, and reach for it only once a stateful pattern is duplicated or a component's logic is worth isolating.

Backend Analogy

A custom hook is the React equivalent of extracting a reusable @Service or utility class in Spring: you lift shared, stateful behaviour out of many components (controllers) into one named unit they depend on. Crucially it shares behaviour, not a shared instance - each component that calls the hook gets its own private state, like injecting a prototype-scoped bean rather than a singleton. Composing custom hooks that call other hooks is like a service that autowires and orchestrates other services. When you truly need one shared value across components, that's a singleton-scoped bean - React Context or a store - not a plain custom hook.

Key Insights
  • A custom hook is any use-prefixed function that calls other hooks; there's no special API beyond the naming convention.
  • Custom hooks reuse logic, not state - each caller gets its own independent copy of the state inside.
  • For shared state across components you need Context or a store; a custom hook alone won't do it.
  • The use prefix makes the linter apply the Rules of Hooks and exhaustive-deps to the hook's body.
  • A custom hook obeys the Rules of Hooks: call it at the top level of a component or another hook, never conditionally.
  • Return a tuple (as const) like useState for one or two values; return an object for several named values.
  • Wrap returned functions/objects in useCallback/useMemo when consumers rely on stable referential identity.
  • Custom hooks compose - one hook can call other custom hooks, letting you build small focused pieces.
  • Extract a hook once a stateful pattern is duplicated or a component's inline logic is worth isolating - not prematurely.
  • Custom hooks are unit-testable in isolation with React Testing Library's renderHook.

Worked Code

useFetch: the canonical reusable data-fetching hook
TSX
import { useState, useEffect } from 'react';

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

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;                     // race guard
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then((r) => {
        if (!r.ok) throw new Error('HTTP ' + r.status);
        return r.json();
      })
      .then((json) => { if (!cancelled) setData(json as T); })
      .catch((e) => {
        if (!cancelled && e.name !== 'AbortError') setError(e.message);
      })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; controller.abort(); }; // cleanup
  }, [url]);                                   // re-fetch when the url changes

  return { data, loading, error };
}

// Usage - one line, fully reusable, each caller gets its own state:
function Profile({ id }: { id: string }) {
  const { data, loading, error } = useFetch<User>('/api/users/' + id);
  if (loading) return <Spinner />;
  if (error) return <p>Error: {error}</p>;
  return <h1>{data?.name}</h1>;
}
Return shapes: tuple (like useState) vs object
TSX
import { useState, useCallback } from 'react';

// TUPLE: mirrors useState; caller renames freely. 'as const' -> precise types.
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []); // stable identity
  return [value, toggle] as const;             // type: readonly [boolean, () => void]
}

// OBJECT: clearer when returning several named values (order-independent).
function useCounter(start = 0) {
  const [count, setCount] = useState(start);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(start), [start]);
  return { count, increment, decrement, reset };
}

function Demo() {
  const [open, toggle] = useToggle();          // tuple destructure
  const { count, increment, reset } = useCounter(10); // object destructure
  return (
    <div>
      <button onClick={toggle}>{open ? 'Hide' : 'Show'}</button>
      <button onClick={increment}>{count}</button>
      <button onClick={reset}>reset</button>
    </div>
  );
}
useDebounce + composition (a hook calling a hook)
TSX
import { useState, useEffect } from 'react';

// A focused hook: returns the value only after it stops changing for 'delay' ms.
function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id);             // cancel the pending update
  }, [value, delayMs]);
  return debounced;
}

// COMPOSITION: a hook built from other hooks (useDebounce + useFetch).
function useSearch(query: string) {
  const debouncedQuery = useDebounce(query, 300);            // wait for typing to settle
  return useFetch<Result[]>('/api/search?q=' + encodeURIComponent(debouncedQuery));
}
useLocalStorage: a stateful subscription-style hook
TSX
import { useState, useCallback } from 'react';

// State synced to localStorage; each caller keeps its own independent value.
function useLocalStorage<T>(key: string, initial: T) {
  const [stored, setStored] = useState<T>(() => {           // lazy init: read once
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initial;
    } catch {
      return initial;
    }
  });

  const setValue = useCallback((value: T) => {
    setStored(value);
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      /* quota or private-mode error - ignore */
    }
  }, [key]);

  return [stored, setValue] as const;
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Custom hooks share logic, not state (plain-JS simulation)

Interview-Ready Q&A

A custom hook is a use-prefixed function that encapsulates reusable stateful logic by calling other hooks internally. Create one when the same stateful pattern - data fetching, a toggle, a subscription, a form field, debouncing - appears in more than one component, or when a single component's inline hook logic is large enough that extracting it improves readability and testability. Avoid extracting prematurely.

Things to Remember
  • 1A custom hook is any use-prefixed function that calls other hooks.
  • 2It reuses logic, not state - each caller gets its own independent state.
  • 3For shared state across components use Context or a store, not just a hook.
  • 4The use prefix makes the linter enforce the Rules of Hooks inside it.
  • 5Obey the Rules of Hooks: call it at the top level, never conditionally.
  • 6Tuple (as const) for one or two values, object for several named values.
  • 7Stabilize returned functions/objects with useCallback/useMemo when identity matters.
  • 8Hooks compose: one custom hook can call other custom hooks.
  • 9Extract once a pattern is duplicated - don't abstract prematurely.
  • 10Custom hooks are unit-testable in isolation via renderHook.

References & Further Reading