Custom React Hooks: When to Extract and When to Leave It Inline

Custom React Hooks: When to Extract and When to Leave It Inline
Brandon Perfetti

Technical PM + Software Engineer

Topics:React Best PracticesFrontend Architecture
Tech:ReactJavaScriptTypeScript

Most teams don’t struggle because they write too few hooks. They struggle because they extract the wrong logic into hooks at the wrong time.

A custom hook should reduce cognitive load, not just reduce lines of code.

This guide gives you a practical decision framework for when extraction improves architecture and when it creates indirection that slows delivery.

The core mistake: line-count optimization

A common anti-pattern:

  • component feels long,
  • logic gets moved into useSomething,
  • readability gets worse because behavior is now split across files with vague naming.

A shorter component is not automatically a better component.

The question is:

Does extraction create a clearer behavior boundary that multiple consumers can use correctly?

When extraction is a good idea

Extract to a hook when all are true:

  1. The behavior is coherent (one clear responsibility).
  2. The behavior is reused or expected to be reused soon.
  3. The hook API can stay small and intention-revealing.
  4. The behavior changes independently from presentation.

Examples that usually belong in hooks:

  • debounced search lifecycle,
  • intersection observer state,
  • keyboard shortcut handling,
  • async resource loading with cancellation.

When to keep logic inline

Keep logic inline when:

  • the behavior is highly local and still evolving,
  • extraction would require too many parameters,
  • naming is vague (useData, useUtils, usePageStuff),
  • the hook would return a large kitchen-sink object.

In plain English: if you cannot name the hook by behavior in one clear sentence, you probably should not extract it yet.

Good extraction example: debounced query state

type UseDebouncedQueryOptions<T> = {
  query: string;
  enabled?: boolean;
  delayMs?: number;
  fetcher: (query: string, signal: AbortSignal) => Promise<T[]>;
};

export function useDebouncedQuery<T>({
  query,
  enabled = true,
  delayMs = 300,
  fetcher,
}: UseDebouncedQueryOptions<T>) {
  const [data, setData] = React.useState<T[]>([]);
  const [loading, setLoading] = React.useState(false);
  const [error, setError] = React.useState<Error | null>(null);

  React.useEffect(() => {
    if (!enabled || !query.trim()) {
      setData([]);
      setError(null);
      return;
    }

    const ctrl = new AbortController();
    const timer = setTimeout(async () => {
      try {
        setLoading(true);
        setError(null);
        const next = await fetcher(query, ctrl.signal);
        setData(next);
      } catch (e) {
        if (!(e instanceof DOMException && e.name === "AbortError")) {
          setError(e as Error);
        }
      } finally {
        setLoading(false);
      }
    }, delayMs);

    return () => {
      ctrl.abort();
      clearTimeout(timer);
    };
  }, [query, enabled, delayMs, fetcher]);

  return { data, loading, error };
}

This extraction works because it captures one behavior boundary cleanly.

Bad extraction example: the “god hook”

Smell profile:

  • performs fetch + validation + analytics + routing + modal orchestration,
  • takes many unrelated parameters,
  • exposes many unrelated values.

This is not reuse. It is hidden complexity.

Fix:

  • split into focused hooks or keep local logic inline until boundaries stabilize.

Hook API design rules that scale

Before implementation, design the API:

  • name by behavior (useDisclosure, useDebouncedQuery, useKeyboardNav),
  • keep inputs explicit,
  • return only what consumers need,
  • avoid leaking implementation details.

Bad:

const stuff = useFeatureEngine(config);

Better:

const { open, openPanel, closePanel } = useDisclosure(false);

Hook quality checks before shipping

Use this in PR review:

  1. Can purpose be explained in one sentence?
  2. Is responsibility singular?
  3. Is API smaller than logic it replaced?
  4. Are side effects explicit and predictable?
  5. Are dependencies correctly modeled?
  6. Would a new engineer use this correctly without reading internals?

If multiple answers are “no,” request inline refactor or narrower extraction.

Testing strategy

Test at two layers:

  • Hook behavior tests: transitions, retries, cancellation, edge cases.
  • Consumer integration tests: verify real component usage contract.

Keep domain logic in pure helpers where possible, then test helpers with fast unit tests and hook orchestration separately.

Migration strategy: introducing hooks safely

  1. Identify one repeated behavior in two real components.
  2. Extract minimal version.
  3. Migrate one consumer first.
  4. Validate readability + bug rate.
  5. Migrate remaining consumers after API stabilizes.

Do not pre-generalize for hypothetical future screens.

Common anti-patterns

  • Extracting for style consistency instead of behavior reuse.
  • Hook names that describe implementation, not intent.
  • Hooks with broad option bags and hidden defaults.
  • Constantly fighting dependency arrays due to poor state boundaries.

Closing

Custom hooks are high leverage when they package stable behavior into a narrow, clear API.

They become drag when used as a hiding place for complexity.

Use extraction as an architectural decision, not a formatting habit. If intent gets clearer and change gets safer, extract. If not, keep it inline and refactor locally first.