2026

February 4, 2026

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.

S
Sascha Becker
Author

20 min read

useEncapsulation: Why Every React Hook Deserves a Home

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:

tsx
function UserDirectory() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const resetSearch = () => setSearchQuery('');
useEffect(() => {
setIsLoading(true);
fetchUsers(searchQuery)
.then(setUsers)
.finally(() => setIsLoading(false));
}, [searchQuery]);
return (
// ... JSX using all of the above
);
}

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.

Extract the modal logic into a custom hook:

tsx
function useModal(initialState = false) {
const [isOpen, setIsOpen] = useState(initialState);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen(prev => !prev);
return { isOpen, open, close, toggle };
}

Extract the search logic into another:

tsx
function useSearch(fetcher: (query: string) => Promise<User[]>) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const reset = () => setQuery('');
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetcher(query)
.then(data => { if (!cancelled) setResults(data); })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [query, fetcher]);
return { query, results, isLoading, handleChange, reset };
}

Now the component reads like a table of contents:

tsx
function UserDirectory() {
const modal = useModal();
const search = useSearch(fetchUsers);
return (
// ... 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.

tsx
type ModalState = { isOpen: boolean; isAnimating: boolean };
type ModalAction =
| { type: 'OPEN' }
| { type: 'CLOSE' }
| { type: 'ANIMATION_END' };
function modalReducer(state: ModalState, action: ModalAction): ModalState {
switch (action.type) {
case 'OPEN':
return { isOpen: true, isAnimating: true };
case 'CLOSE':
return { isOpen: false, isAnimating: true };
case 'ANIMATION_END':
return { ...state, isAnimating: false };
default:
return state;
}
}
function useModal(initialState = false) {
const [state, dispatch] = useReducer(modalReducer, {
isOpen: initialState,
isAnimating: false,
});
const open = useCallback(() => dispatch({ type: 'OPEN' }), []);
const close = useCallback(() => dispatch({ type: 'CLOSE' }), []);
const toggle = useCallback(() => {
dispatch(state.isOpen ? { type: 'CLOSE' } : { type: 'OPEN' });
}, [state.isOpen]);
return { isOpen: state.isOpen, isAnimating: state.isAnimating, open, close, toggle };
}

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
PatternWhen to useExamples
use + NounManages a specific piece of stateuseModal, useAuth, useCart
use + VerbPerforms an action or side effectuseFetch, useDebounce, useIntersect
use + Noun + StateEmphasizes state managementuseFormState, useSelectionState
use + On/Handle + EventWraps event handler logicuseOnClickOutside, useOnKeyPress
Name the Behavior, Not the Implementation
tsx
// Bad - the name describes the implementation
function useStateWithCallback() { ... }
// Good - the name describes the behavior
function useNotification() { ... }

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:

tsx
// This is NOT a hook - it calls no hooks
function sortUsers(users: User[], key: keyof User): User[] {
return [...users].sort((a, b) => /* ... */);
}
// This IS a hook - it uses useState
function useSortedUsers(users: User[], key: keyof User) {
const [sortKey, setSortKey] = useState(key);
const sorted = useMemo(() => sortUsers(users, sortKey), [users, sortKey]);
return { sorted, sortKey, setSortKey };
}

The use prefix is a promise: "I contain React state or effects." Breaking that promise confuses both the linter and your colleagues.

Patterns

State + Handlers

The most common custom hook shape: group a piece of state with its associated handlers.

tsx
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}

Simple. Focused. Every line serves a single concept.

Derived State

When you need to compute values from existing state, derive them inside the hook rather than storing them separately:

tsx
function usePasswordStrength(password: string) {
const strength = useMemo(() => {
if (password.length === 0) return 'none';
if (password.length < 6) return 'weak';
if (password.length < 10) return 'medium';
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[^A-Za-z0-9]/.test(password);
return hasUpper && hasNumber && hasSpecial ? 'strong' : 'medium';
}, [password]);
const colors = { none: 'grey', weak: 'red', medium: 'orange', strong: 'green' } as const;
const color = colors[strength];
return { strength, color };
}

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.

Composition

Hooks can call other hooks. This is how you build complex behavior from simple pieces:

tsx
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;
}
function useDebouncedSearch(fetcher: (q: string) => Promise<User[]>) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const [results, setResults] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
const controller = new AbortController();
setIsLoading(true);
fetcher(debouncedQuery)
.then(data => {
if (!controller.signal.aborted) setResults(data);
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [debouncedQuery, fetcher]);
return { query, setQuery, results, isLoading };
}

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
function useToggle(initial = false): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}
const [isVisible, toggleVisibility] = useToggle(); // Clear
const { user, logout } = useAuth(); // Also clear

Anti-Patterns

1. The God Hook

A hook that does everything is no better than a component that does everything:

tsx
// Don't do this
function useApp() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const [cart, setCart] = useState([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// ... 200 more lines
}

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
function useIsOpen() {
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
function useFormattedPrice(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:

tsx
// Do this instead
function useFormattedPrice(cents: number) {
return useMemo(() => `$${(cents / 100).toFixed(2)}`, [cents]);
}

Or even simpler, this does not need to be a hook at all:

tsx
function formatPrice(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:

tsx
// Leaks memory on unmount
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handler = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handler);
// Missing: return () => window.removeEventListener('resize', handler);
}, []);
return size;
}

This leaks. Every time the component mounts, a new listener is added and never removed. The fix:

tsx
function useWindowSize() {
const [size, setSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
});
useEffect(() => {
const handler = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
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
function useCounter() {
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:

tsx
// Don't do this
function useErrorBanner(error: string | null) {
const banner = error ? <div className="error">{error}</div> : null;
return { banner };
}
// Do this - return data, let the component render
function useError() {
const [error, setError] = useState<string | null>(null);
const clear = useCallback(() => setError(null), []);
return { error, setError, clear };
}

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:

json
{
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "(useCustomEffect|useAnimationFrame)"
}]
}

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.

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:

js
// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
import useEncapsulation from "eslint-plugin-use-encapsulation";
export default [
{
plugins: {
"react-hooks": reactHooks,
"use-encapsulation": useEncapsulation,
},
rules: {
// Core hooks correctness
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
// Enforce encapsulation
"use-encapsulation/prefer-custom-hooks": ["warn"],
// Catch god hooks
"max-lines-per-function": ["warn", {
max: 80,
skipBlankLines: true,
skipComments: true,
}],
"complexity": ["warn", 10],
"max-statements": ["warn", 15],
},
},
];

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:

tsx
type ValidationRule<T> = {
validate: (value: T[keyof T], values: T) => boolean;
message: string;
};
type FieldConfig<T> = {
initialValue: T[keyof T];
rules?: ValidationRule<T>[];
};
type FormConfig<T> = {
[K in keyof T]: FieldConfig<T>;
};
function useForm<T extends Record<string, unknown>>(config: FormConfig<T>) {
type Errors = Partial<Record<keyof T, string>>;
// config is expected to be stable (defined outside render or memoized).
// Deriving initialValues once avoids recomputation.
const [initialValues] = useState<T>(() => {
const values = {} as T;
for (const key in config) {
values[key] = config[key].initialValue;
}
return values;
});
const [values, setValues] = useState<T>(initialValues);
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const errors = useMemo<Errors>(() => {
const result: Errors = {};
for (const key in config) {
const rules = config[key].rules ?? [];
for (const rule of rules) {
if (!rule.validate(values[key], values)) {
result[key] = rule.message;
break;
}
}
}
return result;
}, [values, config]);
const isValid = Object.keys(errors).length === 0;
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues(prev => ({ ...prev, [field]: value }));
}, []);
const setFieldTouched = useCallback((field: keyof T) => {
setTouched(prev => ({ ...prev, [field]: true }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
const handleSubmit = useCallback(
(onSubmit: (values: T) => Promise<void>) => async (e: FormEvent) => {
e.preventDefault();
// Read current state at submission time via setters
setTouched(() => {
const allTouched: Partial<Record<keyof T, boolean>> = {};
for (const key in config) allTouched[key] = true;
return allTouched;
});
// Access latest values through state setter to avoid stale closure
setValues(currentValues => {
const currentErrors: Errors = {};
for (const key in config) {
for (const rule of config[key].rules ?? []) {
if (!rule.validate(currentValues[key], currentValues)) {
currentErrors[key] = rule.message;
break;
}
}
}
if (Object.keys(currentErrors).length > 0) return currentValues;
setIsSubmitting(true);
onSubmit(currentValues).finally(() => setIsSubmitting(false));
return currentValues;
});
},
[config]
);
return {
values,
errors,
touched,
isValid,
isSubmitting,
setValue,
setFieldTouched,
reset,
handleSubmit,
};
}

Usage:

tsx
function SignupForm() {
const form = useForm({
email: {
initialValue: '',
rules: [
{ validate: v => typeof v === 'string' && v.length > 0, message: 'Required' },
{ validate: v => typeof v === 'string' && v.includes('@'), message: 'Invalid email' },
],
},
password: {
initialValue: '',
rules: [
{ validate: v => typeof v === 'string' && v.length >= 8, message: 'Min 8 characters' },
],
},
});
return (
<form onSubmit={form.handleSubmit(async (values) => {
await api.signup(values.email, values.password);
})}>
<input
value={form.values.email}
onChange={e => form.setValue('email', e.target.value)}
onBlur={() => form.setFieldTouched('email')}
/>
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
{/* ... password field, submit button */}
</form>
);
}

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.


S
Written by
Sascha Becker
More articles