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

Technical PM + Software Engineer
Teams love DRY. We also love extracting things into smaller pieces. But DRY-driven extraction, especially of custom React hooks, often shrinks future readability and increases cognitive load. I see two recurring anti-patterns: premature extraction of one-off logic into a reused hook that ends up with a dozen options, and delaying extraction until every file copies the same messy imperative logic. This article gives you a practical framework to decide when to extract a custom hook, concrete before/after examples you can apply immediately, and a migration recipe so refactors are low-risk.
The rule of thumb most devs get wrong
Many teams apply a simple rule: if the same code appears in two places, extract it. That’s well-intentioned but incomplete. Two identical implementations in different components are a signal, not a prescription. The missing pieces are intent, stability, and composition: why are these two components implementing the logic, how likely is it to change, and will a shared hook actually make each component simpler or just more abstract?
My preferred rule of thumb: extract when an abstraction clarifies intent and reduces cognitive load for component authors and maintainers. If extraction forces callers to understand a new configuration surface or fish through a feature-flagged hook to opt in behavior, it often hurts readability.
- Signal vs. prescription: duplication hints at extraction, not automatic justification.
- Ask: Does the abstraction express intent or merely repackage options?
- Prefer small, focused hooks that read like verbs: useConfirmSave, usePolling, useFormValidation.
Decision factors: change surface, reuse shape, and intent
When evaluating code for extraction, treat three lenses as equally important: how much of the implementation is likely to change (change surface), how similar the callers are (reuse shape), and whether a named abstraction clarifies the purpose (intent).
Change surface reduces technical debt: if multiple components share logic but their differences are small and stable (parameters), extraction is helpful. But if they’ll diverge, a shared hook becomes a brittle leaky abstraction.
- Change surface: Prefer extraction when the shared portion is large and stable.
- Reuse shape: Extract when callers use the logic in similar ways (same lifecycle, same inputs/outputs).
- Intent: Extract when a name solves a question like "what is this doing?" rather than "how is it configured?"
Concrete checklist before extracting a hook
Before you open a new file, run this checklist. It’s terse because real codebases benefit from fast, repeatable heuristics.
If you answer yes to at least two of the first three and no to the two risk questions, extraction is likely justified.
- Shared logic appears in 3+ places OR duplication causes bugs/maintenance burdens.
- Callers share the same lifecycle and data flow (mount, update, cleanup patterns match).
- Abstraction gives a clear name that explains intent (e.g., useDebouncedValue is clearer than useUtilityForInput).
- Risk check: Does extraction require an explosion of options/flags for one-off differences? If yes, avoid.
- Risk check: Will the hook hide essential context (props/state) that future readers need to see inline? If yes, avoid.
Before/after examples: make the decision visible
Example 1: Debouncing an input. Before: each component duplicates a useEffect with setTimeout and cleanup. After: extract useDebouncedValue to encapsulate the effect and return stable value and cancel method.
Before extraction the caller shows implementation details that distract from intent. After extraction the component code reads: const debounced = useDebouncedValue(value, 300), which immediately communicates purpose.
- Before (inline): const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), 300); return () => clearTimeout(t); }, [value]);
- After (hook): function useDebouncedValue(value, delay = 300) { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), delay); return () => clearTimeout(t); }, [value, delay]); return v; } // Caller: const debounced = useDebouncedValue(value);
- Example 2: Data fetching with side effects. If each component needs different query params, error handling, and caching rules, an extracted hook must be designed intentionally (small core + optional adapters) or you risk a flag-laden monster.
When not to extract: common anti-patterns
There are situations where keeping logic inline is the more pragmatic choice. The goal is readable, maintainable code—not the maximum number of extracted modules.
Inline code surfaces important context (local state, prop interactions, immediate side effects) that readers need during debugging or feature work. If extraction would hide that context behind an API that only a future reader will understand after opening the hook, keep it inline.
- One-off oddball behavior: leave inline until patterns actually repeat.
- Highly-coupled logic with local state transitions: prefer inline to keep state transitions visible.
- UI-specific sequences where markup and side effects are interleaved: inline keeps the story intact.
- Avoid extracting early if the team is still exploring behavior—extract once the pattern stabilizes.
Practical migration strategy and testing guidance
When you decide to extract, follow a low-risk migration recipe: copy, expose, test, switch callers, remove duplication. The goal is atomic, reviewable steps that minimize churn and make rollback simple.
Tests are your safety net. Extract the logic into a hook and unit-test the hook's behavior so components can be switched confidently. If you don’t have test coverage, introduce tests for the existing inline behavior before extracting.
- Step 1: Copy the inline logic into a new hook file; do not delete existing code yet.
- Step 2: Write focused unit tests for the hook inputs/outputs and side effects (simulate timers, mocks for fetch, etc.).
- Step 3: Swap one caller to use the new hook; run tests and manual smoke tests.
- Step 4: Gradually update other callers. If differences appear, either parameterize clearly or accept divergence and keep variants separate.
- Step 5: Remove inline duplicates when all callers are switched and tests green.
Conclusion
Abstraction is a tool, not a virtue-signaling exercise. A well-extracted hook reduces surface area, communicates intent, and centralizes behavior. A poorly extracted hook hides details, increases configuration complexity, and slows down feature work. Use the checklist and migration recipe above: extract when the abstraction clarifies intent and stabilizes duplicated change surface, keep logic inline when visibility matters, and always back refactors with tests. In practice, favor small, well-named hooks that read like the action they encapsulate.
Action Checklist
- Apply the checklist to one duplication in your repo this week; measure whether the hook reduced or increased files you needed to touch for a small change.
- Extract a small hook (e.g., useDebouncedValue) following the migration recipe and add unit tests for it.
- Audit existing hooks that accept many boolean flags; consider splitting into focused hooks to improve clarity.