#React#TypeScript

React Custom Hook Architecture: Extraction, Composition, and Testing

Hooks are more than useState/useEffect: custom Hooks are React's "unit of reuse." This article explains when to extract custom Hooks, how Hooks compose, dependency arrays and stale closures, testing with renderHook + act, and why not to over-abstract Hooks.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

React Custom Hook Architecture: Extraction, Composition, and Testing

Many teams have been writing React for three years, yet the only custom Hook you can find in their projects is useWindowSize. This is not because they lack reusable logic. Quite the opposite: their components contain extensive duplicated state management, side effects, and data fetching; that logic simply has never been "extracted."

Custom Hooks are not an advanced technique. They are the only unit of reuse React provides. Before Hooks existed, we used HOCs (higher-order components) and render props to reuse logic, and both approaches became nesting hell. The revolutionary part of Hooks is not "function components," but that they give us a way to reuse stateful logic without introducing a new component hierarchy.

But that does not mean "everything should be a Hook." This article clarifies four things: when to extract, how to compose, how to test, and when not to extract.

1. Custom Hooks Are React's Unit of Reuse

First, calibrate your mental model: a custom Hook is fundamentally just a function. There is no magic to it. Its only constraints are that it must start with use and call other Hooks internally.

typescript
// This is a custom Hook. There is nothing mysterious about it.
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

Why call it a "unit of reuse"? Comparing it with three historical approaches makes the answer clear:

HOC (higher-order component): withAuth(withTheme(withRouter(Component))) -- three layers of wrapping, opaque prop origins, TypeScript type hell, and a pile of anonymous components in DevTools during debugging.

render props: <Auth>{({ user }) => <Theme>{({ theme }) => ...}</Theme>}</Auth> -- a nesting pyramid, the JSX version of callback hell.

Custom Hook: const { user } = useAuth(); const { theme } = useTheme(); -- flat, linear, automatically inferred types, and visible in DevTools.

Hooks win because they do not introduce a new component tree level. Reusing logic no longer requires "wrapping components"; it is as natural as calling a function. This is why Hooks truly replaced HOCs and render props.

In one sentence: custom Hooks unify the way we "reuse stateful logic" and "reuse ordinary functions."

2. When to Extract a Custom Hook

Not every piece of logic should become a Hook. I have seen people wrap const fullName = firstName + ' ' + lastName in useFullName() -- that is pathological over-abstraction.

There are three reasonable signals for extracting a custom Hook:

1. Repeated State Logic

The same useState + useEffect + handler combination appears in two or more components:

tsx
// In the search box
function SearchBox() {
  const [query, setQuery] = useState('');
  const [debounced, setDebounced] = useState('');
  useEffect(() => {
    const t = setTimeout(() => setDebounced(query), 300);
    return () => clearTimeout(t);
  }, [query]);
  // ...
}

// The same code is written again in the username input -- that is the signal
function UsernameInput() {
  const [name, setName] = useState('');
  const [debounced, setDebounced] = useState('');
  useEffect(() => {
    const t = setTimeout(() => setDebounced(name), 300);
    return () => clearTimeout(t);
  }, [name]);
  // ...
}

This duplication should be extracted into useDebounce. The second time you handwrite the same code is the signal; do not wait for the third.

2. Complex Side Effects

When a useEffect contains subscriptions, unsubscriptions, cleanup functions, state synchronization, and then business logic on top, component readability quickly collapses. Extract the side effect so the component cares only about "what data do I need," not "how does the data arrive."

Typical examples include data fetching, WebSocket subscriptions, and Intersection Observer. They have lifecycles, require cleanup, and are naturally suited to Hook encapsulation.

3. Stateful Logic Shared Across Components

useAuth, useTheme, useCurrentUser, and useFeatureFlag are all "cross-cutting concerns." Multiple unrelated components need them, and passing them through props layer by layer (prop drilling) is a disaster. Moving them into Hooks, usually together with Context, solves the problem once and for all.

The criterion is simple: if this logic remains meaningful when separated from a specific component, it should be an independent Hook. useUserProfile(userId) is meaningful; useButtonHoverState is not -- it lives and dies with the button component.

3. Hook Composition: Hooks Calling Hooks

This is the most powerful and most easily overlooked capability of custom Hooks: a Hook can call other Hooks.

That means you can build complex logic in layers. Lower-level, atomic Hooks provide basic capabilities, while higher-level Hooks compose them into business semantics.

Here is a real example. Suppose we need to build useAuth, which must:

  • Read the token from localStorage
  • Listen for token changes
  • Provide login/logout

Do not cram everything into one Hook. Split it into atomic Hooks, then compose them:

typescript
// Lower-level atomic Hook: handles localStorage reads and writes
function useStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exhausted or private mode: fail silently
    }
  }, [key, value]);

  return [value, setValue] as const;
}

// Middle layer: handles the token's storage semantics
function useToken() {
  const [token, setToken] = useStorage<string | null>('auth_token', null);
  return { token, setToken, isAuthenticated: token !== null };
}

// Top layer: business semantics
function useAuth() {
  const { token, setToken, isAuthenticated } = useToken();
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    if (!token) {
      setUser(null);
      return;
    }
    fetchCurrentUser(token).then(setUser).catch(() => setToken(null));
  }, [token, setToken]);

  const login = useCallback(
    async (credentials: Credentials) => {
      const { token: newToken } = await loginApi(credentials);
      setToken(newToken);
    },
    [setToken],
  );

  const logout = useCallback(() => setToken(null), [setToken]);

  return { user, isAuthenticated, login, logout };
}

The advantage of this layering is that every layer can be tested and reused independently. useStorage can be used on a settings page, useToken can be used where only login state matters and user information is unnecessary, and useAuth is the complete solution.

When composing Hooks, there is only one rule to remember: React's Rules of Hooks. You cannot call Hooks inside conditions, loops, or nested functions. When composing Hooks, the called Hook must execute unconditionally at the top level of the composed Hook. This is a hard React constraint; violating it causes render order to break.

4. Correct Dependency Management and the Stale Closure Trap

This section is a frequent source of incidents. The second argument of useEffect, useMemo, and useCallback (the dependency array) is the result of React negotiating with closures. Misunderstand it, and you will get two kinds of bugs:

  1. Infinite rerenders: putting an object or function whose reference changes every time into dependencies.
  2. stale closure: omitting a dependency, so the effect uses an "old" value.

What Is a Stale Closure?

typescript
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // count here is always 0!
      // This effect only ran once on mount, so the closure captured the initial count
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // <-- Empty dependency array, so the effect runs only once

  return <div>{count}</div>;
}

This component's number will always stop at 1. The count captured by the setInterval callback is 0 from the first render, so 0 + 1 = 1, and the next time, 0 + 1 is still 1.

Fix one: use a functional update so it does not depend on an external value.

typescript
useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // c is always current
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix two: add count to the dependencies (but this clears and recreates the timer every second, which performs poorly).

Fix three: use a ref to store the latest value, then read the ref in the effect.

Principles for Managing Dependency Arrays

Do not write dependency arrays "by feel." The rule is mechanical:

  • List every reactive value used in an effect (state, props, derived variables).
  • Use ESLint's react-hooks/exhaustive-deps rule to enforce this -- it tells you what is missing. If it warns and you "intentionally" do not want to add it, 99% of the time you should refactor instead of disabling the rule.

If an effect has too many dependencies and triggers every time, the right response is not // eslint-disable-next-line, but rather:

  • Use useCallback / useMemo to stabilize dependencies.
  • Use a reducer to combine multiple pieces of state, using dispatch instead of multiple state setters (dispatch references are always stable).
  • Split the effect so each effect cares about only one thing.

ref: The Escape Hatch from Closures

When you genuinely need the latest value inside an effect that "runs only once," use a ref:

typescript
function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value; // update on every render
  return ref;
}

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(countRef.current + 1); // always the latest value
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

useLatest is a classic utility Hook in the community. Its essence is simply making the need to "read the latest value" explicit.

The golden rule for dependency arrays: do not deceive them. If they say a dependency is missing, add it; if you cannot add it, refactor; if you cannot refactor, use a ref. Never disable lint.

5. Testing Custom Hooks

Hooks cannot be called directly outside a component because they depend on React's rendering context. To test a Hook, use renderHook from @testing-library/react together with act.

renderHook + act Basics

typescript
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

test('useToggle flips state after toggle is called', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => {
    result.current[1](); // call toggle
  });

  expect(result.current[0]).toBe(true);
});

Two key points:

  1. result.current: always points to the Hook's return value from its latest render. It updates after every rerender, but you must use result.current rather than destructuring it with const [on] = result.current, which would retain the old value.
  2. act: any operation that causes a state update, such as calling setState, resolving a promise, or triggering a timer, must be wrapped in act(). Otherwise React warns, and assertions may be based on state that has not been flushed.

Testing a Hook with Effects: useDebounce as an Example

typescript
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest'; // or jest

test('useDebounce updates the debounced value after the delay', () => {
  vi.useFakeTimers();

  const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
    initialProps: { value: 'hello' },
  });

  // Reading immediately should return the initial value
  expect(result.current).toBe('hello');

  // Change the input
  rerender({ value: 'world' });
  expect(result.current).toBe('hello'); // not enough time has passed

  // Advance 200ms
  act(() => {
    vi.advanceTimersByTime(200);
  });
  expect(result.current).toBe('hello'); // still 100ms short

  // Advance to 300ms
  act(() => {
    vi.advanceTimersByTime(100);
  });
  expect(result.current).toBe('world');

  vi.useRealTimers();
});

Note rerender: when testing how a Hook responds to props/state changes, use rerender to simulate a component rerender. Use initialProps to pass the initial value.

Testing an Async Hook Such as useAsync

Use waitFor for async scenarios:

typescript
test('useAsync returns data after resolve', async () => {
  const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' });

  const { result } = renderHook(() => useAsync(fetcher));

  expect(result.current.state).toBe('loading');

  await waitFor(() => {
    expect(result.current.state).toBe('success');
  });
  expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
});

Remember: never make async assertions with a direct expect; wrap them in waitFor or findBy*. Otherwise, you are testing state before it has resolved, which will always fail or always pass.

6. Common Patterns: Write These Five Highly Reusable Hooks

Master these, and they cover 80% of everyday reuse needs.

useDebounce (Debouncing)

typescript
import { useEffect, useState } from 'react';

function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer); // clear the old timer when value or delay changes
  }, [value, delay]);

  return debounced;
}

The cleanup function is the core of debouncing. Every value change reruns the effect and clears the old timer. Only when there has been no change for delay milliseconds after the final change will setState actually run.

useLocalStorage (Persistent State)

typescript
import { useCallback, useEffect, useState } from 'react';

function useLocalStorage<T>(
  key: string,
  initialValue: T,
): readonly [T, (value: T | ((prev: T) => T)) => void] {
  const readValue = useCallback((): T => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (err) {
      console.warn(`useLocalStorage: failed to read ${key}`, err);
      return initialValue;
    }
  }, [key, initialValue]);

  const [storedValue, setStoredValue] = useState<T>(readValue);

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStoredValue((prev) => {
        const next =
          typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
        try {
          window.localStorage.setItem(key, JSON.stringify(next));
        } catch (err) {
          console.warn(`useLocalStorage: failed to write ${key}`, err);
        }
        return next;
      });
    },
    [key],
  );

  // Listen for changes to the same key from other tabs
  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        try {
          setStoredValue(JSON.parse(e.newValue) as T);
        } catch {
          /* ignore */
        }
      }
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [key]);

  return [storedValue, setValue] as const;
}

Note several engineering details: SSR safety (typeof window === 'undefined'), try/catch fallbacks (private mode and exhausted quota), cross-tab synchronization (listening to the storage event), and functional updates. These are the difference between "it runs" and "it does not break in production."

useAsync (Async Request State Machine)

typescript
import { useEffect, useState } from 'react';

type AsyncState<T> =
  | { state: 'idle' }
  | { state: 'loading' }
  | { state: 'success'; data: T }
  | { state: 'error'; error: Error };

function useAsync<T>(
  asyncFn: () => Promise<T>,
  deps: unknown[] = [],
) {
  const [status, setStatus] = useState<AsyncState<T>>({ state: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setStatus({ state: 'loading' });

    asyncFn()
      .then((data) => {
        if (!cancelled) setStatus({ state: 'success', data });
      })
      .catch((error: Error) => {
        if (!cancelled) setStatus({ state: 'error', error });
      });

    return () => {
      cancelled = true; // prevent setState after unmount
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return status;
}

There are two key engineering details here: the cancelled flag prevents setState after a component unmounts, which can trigger React warnings or even memory leaks; deps is explicitly passed by the caller, avoiding placing an unstable function reference like asyncFn directly into dependencies, which would rerun indefinitely.

usePrevious (Get the Previous Render's Value)

typescript
import { useEffect, useRef } from 'react';

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value; // updated after rendering, so this render still returns the old value
  }, [value]);

  return ref.current;
}

Understanding this explains when refs and effects run: useEffect runs only after the DOM is committed, so this render returns ref.current (the old value), then the effect updates it to the new value. The next render can then retrieve this render's "previous" value.

useMediaQuery (Responsive State)

typescript
import { useEffect, useState } from 'react';

function useMediaQuery(query: string): boolean {
  const getMatches = () =>
    typeof window !== 'undefined' ? window.matchMedia(query).matches : false;

  const [matches, setMatches] = useState(getMatches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);

    setMatches(mql.matches); // synchronize once on mount (query may have changed)
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

Note that addEventListener('change', ...) is the modern form; addListener in older code is deprecated. SSR safety also needs to be handled.

7. Do Not Over-Abstract: Not Every Piece of Logic Should Become a Hook

This section may be more important than all the preceding sections combined.

I have seen too many teams wrap every piece of logic longer than three lines in useXxx for the sake of "reuse." The result:

  • useFullName(firstName, lastName) -- returns a concatenated string. This is just a pure function; making it a Hook is unnecessary and sends it through Hook scheduling on every render.
  • useIsEven(number) -- returns number % 2 === 0. Same issue.
  • useFormatDate(date) -- wraps format from date-fns. Same issue.

The criterion is: if the logic does not call any React API (useState, useEffect, useRef, and so on), it should not be a Hook. It should be an ordinary function.

typescript
// Unnecessary abstraction
function useFullName(first: string, last: string) {
  return `${first} ${last}`;
}

// Be an ordinary function
function getFullName(first: string, last: string) {
  return `${first} ${last}`;
}

Another form of over-abstraction is premature abstraction. When only one place uses a piece of logic, keep it inline first. Extract it only when a real second use appears. Do not abstract in advance for "possible future reuse" -- predictions are unreliable, and the interface of a prematurely extracted Hook will almost certainly need to change on its second use, making it more work than keeping it inline initially.

Rule: half of the Rule of Three -- keep it inline for the first use, but extract it when the second use appears. It is better to inline it twice than to extract it prematurely for the sake of "looking clean."

There is another trap in the opposite direction: do not put UI into a Hook just to reduce component levels. A Hook should return data and behavior, not JSX. If useModal() returns a <dialog> JSX fragment, it is really a component and is more appropriately called <Modal>. Hooks handle logic; components handle rendering. Do not blur the responsibilities.

8. Naming Convention: The use Prefix Is Not a Suggestion, It Is Mandatory

One final point, but an important one: custom Hooks must start with use.

This is not a code style preference; it is a hard React constraint. There are two reasons:

  1. The linter depends on it. eslint-plugin-react-hooks determines whether "this is a Hook" from whether a function name starts with use, then checks whether it violates the Rules of Hooks internally. If you name a Hook fetchUser, the linter will not check the order of Hook calls inside it, and bugs slip through.

  2. React DevTools depends on it. DevTools' Hooks debugging panel uses the use prefix to identify and group Hook state. A Hook without the prefix is a black box in the panel.

The reverse is also true: do not name a function that is not a Hook with a use prefix. If useFullName does not call any Hook, it misleads the linter into checking it and misleads teammates into thinking it contains state.

There are also a few practical naming guidelines:

  • Make return values semantic: a tuple like [state, setState] suits simple Hooks (useState style), while an object such as { data, loading, error, refetch } is easier to extend for complex Hooks.
  • Prefer behavior verbs: useDebounce (what it does) is more robust than useDebouncedValue (what it returns), because behavior does not change while return values may expand.
  • Avoid vague names like useData: what data? useUser, useOrders, and useCurrentUser are all far stronger than useData. A vague name is usually a signal that the abstraction boundary has not been thought through.

Summary

  1. Custom Hooks are React's only unit of reuse. They replaced HOCs and render props because reusing stateful logic no longer introduces a new component tree level.
  2. Look for three signals when extracting: repeated state logic, complex side effects, and cross-cutting concerns shared across components. Keep it inline on the first use; extract it on the second.
  3. Hooks can call Hooks -- layered composition (useAuth -> useToken -> useStorage) makes every layer independently testable and reusable.
  4. Do not deceive dependency arrays: if exhaustive-deps warns, add the dependency; if you cannot, refactor; if you cannot refactor, use a ref. Never disable lint. A stale closure is not a React bug; it means you did not list all dependencies.
  5. Test with renderHook + act: wrap state updates in act, wrap async work in waitFor, and use rerender for rerenders. Testing Hooks is much simpler than testing components, so there is no reason not to test them.

One final thought that has repeatedly proven true over the years:

"Whether to use a custom Hook is not a technical question; it is a judgment question. A good Hook abstraction makes code as clear as prose. A bad Hook abstraction makes code harder to understand than repeating it three times. The only difference is whether you are eliminating duplication or trying to look architectural."

Related

Related Content