Custom Hooks: Extracting Logic the Right Way

Custom hooks are often introduced as “reuse for stateful logic.” That description is true and insufficient. In mature codebases, hooks are the boundary where side effects stop being an implementation detail of a screen and become a named, testable capability: fetching with cancellation, subscribing to the window, debouncing input, synchronizing with URL state.

A useful discipline for experienced teams is to treat feature components as orchestration layers: compose hooks, render UI, handle navigation. When business logic sprawls across useEffect blocks in the component body, tests balloon into full render integration suites, dependency mistakes create subtle bugs, and code review becomes archaeology. Extracting hooks is how you keep components readable and effects reviewable.

This section is not a plea to ban useEffect. Effects are the right tool for synchronizing React with external systems. The goal is to concentrate them in hooks with clear contracts so components mostly declare behavior.

Data fetching with cancellation

Network requests race. The user changes a filter; the first response arrives after the second; state updates in the wrong order. AbortController is the browser-standard way to cancel in-flight work. A hook encapsulates the subscription lifecycle so callers see { data, loading, error } instead of ten lines of effect noise.

function useFetch<T>(url: string) {
  const [state, setState] = useState<{
    data: T | null;
    loading: boolean;
    error: Error | null;
  }>({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((error) => {
        if (error.name === "AbortError") return;
        setState({ data: null, loading: false, error });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

Ignoring AbortError in the catch path is intentional: teardown is not a failure mode. For production, you would likely fold in schema validation, deduplication, and integration with a cache layer (TanStack Query, Relay, or similar). The hook still illustrates the invariant: when url changes or the component unmounts, no stale resolution may call setState.

Event listeners and cleanup symmetry

Effects should always return cleanup when they register listeners, timers, or observers. Symmetry here prevents memory leaks and duplicate handlers in strict mode’s double-mount behavior during development.

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handler = () =>
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    window.addEventListener("resize", handler);
    return () => window.removeEventListener("resize", handler);
  }, []);

  return size;
}

TypeScript note: if you SSR this hook, guard window or initialize from a safe default and subscribe only in the browser. The pattern is the same; the platform constraints change.

Debounced derived state

Debouncing is another effect-shaped problem: user types fast, expensive work should run slowly. Encapsulation keeps delay and timer IDs out of every form component.

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

File structure, naming, and testing

Place hooks in dedicated modules, for example src/hooks/useWindowSize.ts. One hook per file scales better in git history and code review than a thousand-line hooks.ts. The use prefix is not cosmetic; it signals hook rules to linting tools and to humans.

Isolation pays off in tests. With Vitest and React Testing Library, renderHook executes a hook without inventing a throwaway component for every case. That matters when you are verifying cleanup, cancellation, and dependency behavior.

import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

describe("useDebounce", () => {
  it("updates after delay", async () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
      initialProps: { value: "a", delay: 100 },
    });
    expect(result.current).toBe("a");
    rerender({ value: "b", delay: 100 });
    expect(result.current).toBe("a");
    await act(async () => {
      vi.advanceTimersByTime(100);
    });
    expect(result.current).toBe("b");
    vi.useRealTimers();
  });
});

The pattern generalizes: advance time for debounce, unmount to assert AbortController aborts, or swap url in useFetch and ensure only the latest response applies.

Refs for stable subscriptions

Sometimes an effect must call a callback that changes every render—event handlers, analytics, imperative APIs— without re-subscribing. The idiomatic fix is a ref that always holds the latest callback, updated in render, while the effect depends on [] or on slow-changing values. That keeps listener registration cheap and avoids both stale closures and dependency churn. Use this deliberately; an empty dependency array with a ref is easy to misuse if the effect was supposed to react to real prop changes.

Dependency arrays: fix, do not silence

eslint-disable-next-line react-hooks/exhaustive-deps is a red flag in team code. It usually means “this effect is correct for reasons ESLint cannot see” but more often means “we have not modeled the effect properly.” Typical root causes:

  • Stale closures because callbacks are not memoized when they should be, or values that should be refs are held in state.
  • Derived state computed in an effect that should be computed during render or with useMemo.
  • Objects or arrays recreated every render and listed as dependencies, causing unnecessary reruns—stabilize with useMemo / useCallback or pass primitives.

The infinite loop footgun

A classic failure mode: an effect writes a value that is also in its dependency array, without a stable termination condition. For example, an effect that calls setX whenever x fails a predicate, where the predicate depends on something setX changes every time. The effect runs again immediately; React warns; the app freezes. The fix is usually to narrow what triggers the effect (derive during render, split state, or compare previous values with a ref).

Standing on shoulders: react-use and usehooks-ts

In 2026, many teams lean on curated hook collections such as react-use and usehooks-ts for battle-tested utilities—debounce, throttle, local storage synchronization, intersection observer, and more. Before copying a hook from a blog post, check whether a maintained library already handles edge cases (SSR, strict mode, TypeScript generics). Your custom hooks should encode domain behavior; generic machinery is often better imported.

Naming and return-shape conventions

Name hooks after behavior, not mechanics: useUserProfile beats useGetUserApi. Return either a tuple for simple pairs ([value, setValue]) or a small object with named fields when the surface has more than two concepts—callers should not remember argument order for five return values. If you return an object, keep field names stable; breaking renames are as painful as breaking prop names for library consumers.

Document which fields are stable across renders (same function identity when memoized) and which are fresh each time. That distinction is what prevents subtle child memoization bugs when hooks feed props into React.memo components.

Philosophy in one sentence

Extract hooks so that components read like specifications and hooks read like imperative adapters to the outside world—network, DOM, clocks, storage—with cleanup and cancellation treated as part of the public contract, not as comments apologizing for useEffect.