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.
Sascha Becker
Author20 min read
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:
tsxfunction 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:
tsxfunction 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:
tsxfunction 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:
tsxfunction 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
useModalhook usesuseStatetoday. Tomorrow you might refactor it touseReducerfor exit animations. The consuming component doesn't change, it still callsmodal.open()and readsmodal.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
useSearchin isolation with a mocked fetcher is straightforward. You test the logic, not the DOM. - Reuse happens naturally. You didn't write
useModalfor 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.
tsxtype 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
| 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 implementationfunction useStateWithCallback() { ... }// Good - the name describes the behaviorfunction 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 hooksfunction sortUsers(users: User[], key: keyof User): User[] {return [...users].sort((a, b) => /* ... */);}// This IS a hook - it uses useStatefunction 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.
The Litmus Test
use.Patterns
State + Handlers
The most common custom hook shape: group a piece of state with its associated handlers.
tsxfunction 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:
tsxfunction 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.
Avoid the useEffect Trap
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:
tsxfunction 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 meaningfunction useToggle(initial = false): [boolean, () => void] {const [value, setValue] = useState(initial);const toggle = useCallback(() => setValue(v => !v), []);return [value, toggle];}const [isVisible, toggleVisibility] = useToggle(); // Clearconst { 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 thisfunction 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 valuefunction 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 thisfunction 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 insteadfunction useFormattedPrice(cents: number) {return useMemo(() => `$${(cents / 100).toFixed(2)}`, [cents]);}
Or even simpler, this does not need to be a hook at all:
tsxfunction 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 unmountfunction 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:
tsxfunction 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;}
The Cleanup Rule
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 countfunction useCounter() {const [count, setCount] = useState(0);const showCount = useCallback(() => {setTimeout(() => alert(count), 1000);}, []); // Missing 'count' in depsreturn { 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 thisfunction useErrorBanner(error: string | null) {const banner = error ? <div className="error">{error}</div> : null;return { banner };}// Do this - return data, let the component renderfunction 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.
eslint-plugin-react-hooks (Official)
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.
Gradual Adoption
"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-functioncatches hooks that have grown too large to understand at a glance. 80 lines is a reasonable starting point, enough for auseReducerwith a few handlers, tight enough to flag hooks that manage five unrelated concerns.complexitymeasures 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-statementslimits the number of statements. If a hook has 20constdeclarations, it is almost certainly doing too much.max-depthcatches 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.jsimport 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:
tsxtype 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 setterssetTouched(() => {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 closuresetValues(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:
tsxfunction 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);})}><inputvalue={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.
Sources & Further Links
- useEncapsulation: Kyle Shevlin
The original article that coined the term 'useEncapsulation' and argues for wrapping all React hooks in custom hooks.
- Reusing Logic with Custom Hooks: React Docs
The official React documentation on custom hooks: when to extract, naming conventions, and how state isolation works.
- You Might Not Need an Effect: React Docs
React's official guide on avoiding unnecessary useEffect calls, one of the most impactful pages in the docs.
- React Hooks Pitfalls: Kent C. Dodds
Five tips for avoiding common hooks mistakes, including stale closures and dependency array issues.
- eslint-plugin-use-encapsulation
Kyle Shevlin's ESLint plugin that enforces the custom hook encapsulation pattern with a prefer-custom-hooks rule.
- eslint-plugin-react-hooks: React
The official React ESLint plugin reference, rules-of-hooks, exhaustive-deps, and the newer React Compiler rules.
