useEncapsulation: Why Every React Hook Deserves a Home
Custom hooks are not just a convenience. They are the primary architecture tool in modern React. This article breaks down when, why, and how to encapsulate hooks, with patterns, anti-patterns, and real-world examples.
useEncapsulation: Why Every React Hook Deserves a Home
There is a moment in every React component's life where it crosses a line. It starts innocent, a single useState, maybe a useEffect for fetching data. Then someone adds a toggle. Then a form field. Then a subscription. Before long, the component function reads like a stream of consciousness: state declarations, event handlers, effects, and refs interleaved with no visible structure.
The code still works. But reading it requires holding the entire function in your head, mentally grouping "these three lines belong together" and "that handler goes with this state." The component has become a flat list of implementation details with no seams.
Custom hooks fix this. Not by adding abstraction for abstraction's sake, but by giving related logic a name and a boundary. They are React's answer to encapsulation, and they are the most underused architecture tool in the ecosystem.
The Problem: Scattered State
Consider a component that manages a modal and a search input:
Four state variables. Four handlers. One effect. All dumped into the same function body. The modal state (isModalOpen, openModal, closeModal) has nothing to do with the search logic (searchQuery, handleSearchChange, resetSearch), but they sit side by side, separated only by the order you happened to write them in.
This is not a readability nitpick. It is a structural problem. As the component grows, the mental cost of understanding which pieces belong together grows linearly with every new hook call.
// ... JSX using modal.isOpen, search.results, etc.
);
}
Two lines. Two named concepts. The implementation details haven't disappeared, they've been relocated to a place where they can be understood in isolation.
Notice that the extracted useSearch hook now includes a cleanup flag (cancelled) that the original inline version was missing. This is not a coincidence, isolating the fetch logic in its own function made the missing cleanup obvious. When an effect lives among twenty other lines in a component, it is easy to overlook a missing return. Inside a focused hook, the gap jumps out at you.
This buys you more than a shorter component:
Dependencies become explicit. A custom hook's function signature is its dependency list. useSearch(fetcher) tells you at a glance: this hook depends on a fetcher function. Nothing else from the outside world.
Internals can evolve freely. The useModal hook uses useState today. Tomorrow you might refactor it to useReducer for exit animations. The consuming component doesn't change, it still calls modal.open() and reads modal.isOpen. The contract is stable even when the implementation evolves.
Testing improves dramatically. Testing a 200-line component that mixes UI with data fetching and event handling is painful. Testing useSearch in isolation with a mocked fetcher is straightforward. You test the logic, not the DOM.
Reuse happens naturally. You didn't write useModal for reuse. You wrote it for encapsulation. But now every modal in your app can use it. Reuse is a side effect of good structure, not the goal.
The consuming component still calls modal.open(). It just now also has access to modal.isAnimating if it wants it.
Naming: The Hardest Part
A custom hook's name is its documentation. Get it wrong, and the abstraction becomes a black box that nobody trusts.
The use Prefix is Non-Negotiable
React's linter enforces the use prefix. Without it, React cannot verify that your function follows the rules of hooks (no conditional calls, no calls inside loops). This is not a convention: it is a technical requirement.
Naming Patterns
Pattern
When to use
Examples
use + Noun
Manages a specific piece of state
useModal, useAuth, useCart
use + Verb
Performs an action or side effect
useFetch, useDebounce, useIntersect
use + Noun + State
Emphasizes state management
useFormState, useSelectionState
use + On/Handle + Event
Wraps event handler logic
useOnClickOutside, useOnKeyPress
Name the Behavior, Not the Implementation
tsx
// Bad - the name describes the implementation
functionuseStateWithCallback(){...}
// Good - the name describes the behavior
functionuseNotification(){...}
A developer reading useNotification() knows what it does. A developer reading useStateWithCallback() knows how it works internally: which is precisely the detail the hook was supposed to hide.
When a Function Should NOT Be a Hook
If your function does not call any React hooks internally, do not give it the use prefix. It is a regular utility function:
No useEffect. No extra state. Just derived values. This is one of the most powerful and least used patterns, many developers reach for useEffect + useState when useMemo alone would suffice.
Avoid the useEffect Trap
If you are using useEffect to update state based on other state or props,
you almost certainly want useMemo or a direct computation during render
instead. useEffect is for side effects, things that happen outside of
React's rendering, like API calls, subscriptions, or DOM measurements.
Composition
Hooks can call other hooks. This is how you build complex behavior from simple pieces:
useDebouncedSearch composes useDebounce without knowing its internals. Each hook solves one problem. Together, they solve a complex one.
Return Shape: Object vs. Tuple
Use tuples when the hook returns two or three related values (like useState itself). Use objects when the hook returns more than three values, or when the values have no natural order. Objects allow destructuring by name, which is self-documenting:
tsx
// Tuple - mirrors useState, position conveys meaning
If your hook manages unrelated concerns, it is not encapsulating, it is just relocating the mess. Each concern should be its own hook: useAuth, useTheme, useNotifications, useCart, useMenu.
2. The Premature Abstraction
Not every useState call needs a custom hook:
tsx
// This hook adds no value
functionuseIsOpen(){
const[isOpen, setIsOpen]=useState(false);
return{ isOpen, setIsOpen };
}
This is just useState with extra steps. A custom hook should earn its existence by grouping multiple related pieces of logic, state, effects, handlers, derived values, not by wrapping a single primitive.
3. useEffect as a State Synchronizer
The single most common anti-pattern in React codebases:
tsx
// Don't do this
functionuseFormattedPrice(cents:number){
const[formatted, setFormatted]=useState("");
useEffect(()=>{
setFormatted(`$${(cents /100).toFixed(2)}`);
},[cents]);
return formatted;
}
This creates an unnecessary render cycle: render with stale value, effect fires, state updates, re-render with correct value. The fix is trivial:
Or even simpler, this does not need to be a hook at all:
tsx
functionformatPrice(cents:number):string{
return`$${(cents /100).toFixed(2)}`;
}
useEffect is for side effects: network requests, subscriptions, DOM mutations, timers. If you are using it to transform data from props or state into other state, you are fighting React instead of working with it.
4. Missing Cleanup
Every subscription, timer, or listener set up in a useEffect must be cleaned up:
If your effect adds something (listener, subscription, timer), it must remove
it. No exceptions. React's Strict Mode in development will unmount and remount
your component to help you catch these bugs, if you see double effects firing,
that is the check working.
5. Stale Closures
Closures capture variables at creation time. If a callback references state but is not recreated when that state changes, it reads stale values:
tsx
// Bug: the alert always shows the initial count
functionuseCounter(){
const[count, setCount]=useState(0);
const showCount =useCallback(()=>{
setTimeout(()=>alert(count),1000);
},[]);// Missing 'count' in deps
return{ count, setCount, showCount };
}
The eslint-plugin-react-hooks exhaustive-deps rule catches this. If the linter says a dependency is missing, it is almost always right.
6. Returning JSX from Hooks
Hooks return data and handlers. They do not return UI:
The component chooses the UI. The hook manages the state. Each does what it is good at.
When Not to Extract
Encapsulation is not the answer to every problem. Sometimes a component with three useState calls and two handlers is perfectly readable as-is. Extracting a hook that is only used once and only contains one state variable adds a layer of indirection without adding clarity.
A good rule of thumb: extract when the logic forms a concept with a name. If you can't name the hook without describing its implementation ("useStateAndEffectForTheThing"), the logic probably doesn't belong in a hook yet. Wait until the concept crystallizes: either through reuse, complexity, or the simple need to read the component without drowning in details.
The goal is not zero hooks in components. The goal is that every hook call in a component reads like a sentence: this component uses authentication, a modal, and a debounced search. When the component reads like a paragraph of intent rather than a wall of mechanism, you have found the right level of extraction.
Tooling: Let Machines Enforce the Discipline
Good habits are easier to maintain when the toolchain has your back. You don't need to rely on code review alone to catch scattered hooks, god hooks, or stale closures. Several ESLint rules, some React-specific, some general, can automate the guardrails discussed in this article.
The baseline. Every React project should have this enabled:
json
{
"rules":{
"react-hooks/rules-of-hooks":"error",
"react-hooks/exhaustive-deps":"warn"
}
}
rules-of-hooks ensures hooks are never called conditionally or inside loops. exhaustive-deps catches stale closures by warning when a dependency is missing from useEffect, useMemo, or useCallback. Do not disable it, if you are fighting it, the code likely needs restructuring, not a suppression comment.
As of v6.1.1, you can also use additionalHooks to apply exhaustive-deps checking to your own custom hooks that accept dependency arrays:
Kyle Shevlin's plugin that enforces the core thesis of this article: don't use React hooks directly in components. Its single rule, prefer-custom-hooks, warns whenever useState, useEffect, useRef, or any other React hook appears directly inside a component rather than inside a custom hook.
json
{
"plugins":["use-encapsulation"],
"rules":{
"use-encapsulation/prefer-custom-hooks":["warn"]
}
}
You can whitelist specific hooks with the allow option if strict enforcement is too aggressive for your codebase. Use this sparingly, the occasional eslint-disable is better than a blanket exception.
Gradual Adoption
Set it to "warn" initially, not "error". Let the team see the warnings in
context for a few sprints before deciding which ones to enforce strictly. This
avoids a wall of red on day one and gives people time to internalize the
pattern.
Catching God Hooks with General ESLint Rules
There is no dedicated "god hook detector" plugin. But general-purpose complexity rules apply to hooks just like any other function:
json
{
"rules":{
"max-lines-per-function":[
"warn",
{
"max":80,
"skipBlankLines":true,
"skipComments":true
}
],
"complexity":["warn",10],
"max-statements":["warn",15],
"max-depth":["warn",3]
}
}
max-lines-per-function catches hooks that have grown too large to understand at a glance. 80 lines is a reasonable starting point, enough for a useReducer with a few handlers, tight enough to flag hooks that manage five unrelated concerns.
complexity measures cyclomatic complexity, the number of independent paths through the function. A hook with a complexity of 15 has too many branches and should be split.
max-statements limits the number of statements. If a hook has 20 const declarations, it is almost certainly doing too much.
max-depth catches deeply nested conditionals and loops inside hooks.
None of these are React-aware, but hooks are functions, and these rules work on all functions.
If you are running React 19+ with the React Compiler, eslint-plugin-react-hooks now includes additional rules like react-hooks/purity and react-hooks/refs that validate whether your components and hooks are safe for automatic memoization. These don't enforce encapsulation directly, but they reward well-structured hooks - the simpler and purer your hook, the more the compiler can optimize it.
Putting It All Together
A practical ESLint configuration that enforces most of the patterns in this article:
This won't catch every anti-pattern. No linter replaces judgment. But it shifts the default: instead of relying on discipline alone, the toolchain nudges you toward encapsulation, flags complexity before it becomes a problem, and prevents the most common correctness bugs automatically.
Real-World Example: A Complete useForm Hook
To tie it all together, here is a production-grade form hook that combines several patterns, state management, derived state, validation, and clean API design:
The hook manages form state, validation, touched tracking, and submission flow. It does not render inputs, decide error styles, or dictate layout. The component owns the UI. The hook owns the logic.