Skip to content
← All posts
By Muhammad Zain Ul Abidin14 min read

Custom Hooks in React + TypeScript: Patterns Senior Devs Use, React 19

Nine production-ready React hooks in TypeScript, the race condition my own tests caught, renderHook testing, and the architecture behind them.

On this page
Custom Hooks in React + TypeScript: Patterns Senior Devs Use, React 19
Watch: Custom Hooks in React + TypeScript: Patterns Senior Devs Use, React 19

When I open a React component and find data fetching, a debounce timer, two event listeners and a localStorage sync all tangled into the render, I know exactly how it got there: one reasonable line at a time. The fix is not discipline. The fix is moving each concern into a custom hook, so the component goes back to describing what it renders. In this post I build nine hooks you can copy into a real project today, I show you how I test them, and I include the part most tutorials skip: before writing this, I ran every hook in a real repo with type-checking, linting and stress tests. The tests caught a genuine race condition in the fetch hook I was about to teach you. That bug, and its one-line fix, are in this post too.

The short answer

A custom hook is a function whose name starts with use and that calls other hooks. That is the entire mechanism. What it buys you is separation: stateful logic (requests, timers, subscriptions, storage) lives in a named, testable function, and your components read like a table of contents instead of a wiring diagram. Below you get nine production-shaped hooks (B1 to B9), the testing setup (B10), the seven anti-patterns I reject on sight, and the three-layer folder architecture that keeps hooks from turning into a junk drawer at scale.

Every code block in this post is the exact file from the companion repo, react-hooks-kit, verified with tsc, ESLint and a 15-test Vitest suite on React 19.3.1. Prefer paper? The one-page cheat sheet has every signature and the anti-pattern list.

What a custom hook really is

Strip away the mystique and three facts remain. First, the name starts with use, and that prefix is a contract: the linter enforces the Rules of Hooks only on functions that carry it. Second, a custom hook calls other hooks; a function that calls none is a plain utility and prefixing it with use is a lie I flag in review. Third, and this is the one people get wrong, a hook shares logic, not state. Two components calling useToggle() get two independent booleans. Hooks share the recipe, never the meal.

The rules themselves are short enough to memorize:

  • Call hooks only at the top level. Never inside a condition, a loop, or a nested function.
  • Call hooks only from components or other hooks.

React tracks your hooks by call order, which is why the first rule exists. Break the order between renders and state lands in the wrong hook. You do not need to be paranoid about this. You need the linter, which brings me to the setup step everyone skips.

Set up the linter before you write a single hook

Install the plugin, add the config, and the compiler-powered react-hooks rules (no synchronous setState in Effects, no reading refs during render, exhaustive dependencies) will catch the mistakes above while you type. Here is the part the official quick-start does not tell you: the bare config snippet lints nothing in a TypeScript project. The recommended preset ships no files field, and flat-config ESLint only lints extensions that some block covers, so every .ts file is silently ignored. Even once files match, ESLint's default parser cannot read TypeScript syntax. I know because I ran the snippet verbatim and watched it pass over the whole repo without checking a single hook.

You need two extra lines of wiring: a block that claims the TS files, and the TypeScript parser.

bash
npm i -D eslint eslint-plugin-react-hooks@latest @typescript-eslint/parser
ts
// eslint.config.js  - B0
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
import tsParser from '@typescript-eslint/parser';

export default defineConfig([
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: { parser: tsParser },
  },
  reactHooks.configs.flat.recommended,
]);

With that wiring in place, the claim becomes true: every hook in this post passes the v7 plugin's rules with zero errors and zero warnings, including the fetch hook's useEffectEvent usage, which the plugin correctly treats as a non-dependency. Wire it first, then write hooks.

Tuple or object: decide the API before the implementation

Every hook you write forces one small design decision: what does it hand back? I use a boring rule and it has never let me down.

Return a tuple [value, action] as constReturn an object { isOpen, open, close }
1 to 3 values, and callers will want to rename them3 or more values, or named fields read better
Mirrors useState: const [on, toggle] = useToggle()Callers destructure only the fields they need
Always add as const, or TypeScript infers a loose arrayEasier to extend later without breaking callers

The as const is not decoration. Without it, useToggle returns (boolean | (() => void) | Dispatch<...>)[] and every destructure is a type error waiting to happen. With it, the tuple is exact. You will see both shapes below, chosen by this rule.

The nine hooks

Each block is one file from the repo, with the usage I actually write next to it. Copy the file, not the idea.

B1 · useToggle, your first hook

ts
// hooks/useToggle.ts
import { useCallback, useState } from 'react';

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle, setOn] as const;
}
tsx
const [isDark, toggleDark] = useToggle();
<button onClick={toggleDark} aria-pressed={isDark}>
  {isDark ? 'Dark' : 'Light'}
</button>

One judgment call: if you run React Compiler, it memoizes for you and the useCallback becomes optional. I keep it in the kit because the kit cannot assume your build runs the compiler, and a stable toggle is safe to place in a caller's dependency array either way.

B2 · useDisclosure, the object API

ts
// hooks/useDisclosure.ts
import { useCallback, useState } from 'react';

export function useDisclosure(initial = false) {
  const [isOpen, setIsOpen] = useState(initial);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);
  const toggle = useCallback(() => setIsOpen((v) => !v), []);
  return { isOpen, open, close, toggle };
}
tsx
const menu = useDisclosure();
<button aria-expanded={menu.isOpen} aria-controls="menu" onClick={menu.toggle}>Menu</button>
{menu.isOpen && <nav id="menu">...</nav>}

Four named members, so the object shape wins by the rule above. Menus, modals, drawers, accordions: this is the hook I paste most often after useToggle.

B3 · useDebouncedValue, generics and cleanup

ts
// hooks/useDebouncedValue.ts
import { useEffect, useState } from 'react';

export function useDebouncedValue<T>(value: T, delayMs = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id); // ← cancels the stale timer on every change
  }, [value, delayMs]);

  return debounced;
}
tsx
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);
// debouncedQuery only updates 400ms after you stop typing

Two things carry this hook. The generic <T> means the same code debounces a string, a number or a filter object, and the return type follows the input with no casts. And the cleanup line is the debounce: every change clears the previous timer, so only the last keystroke in a burst ever fires.

B4 · useFetchJson, the hook this post exists for

This is the most important hook in the set, because it packs four senior-level decisions into about 50 lines:

  1. Discriminated union state. The status field tells TypeScript exactly which other fields exist. No data?: T soup.
  2. AbortController. Changing the URL cancels the in-flight request, which kills the common race where a slow old response lands after a fast new one.
  3. Derived loading state. The hook never calls setState to say "loading". It computes loading from "do I have a result for this URL yet", which removes a whole class of flicker bugs and keeps the compiler-powered lint rules quiet.
  4. useEffectEvent for the parser. Callers can pass an inline parse function without retriggering the fetch on every render.
ts
// hooks/useFetchJson.ts   (React 19.2+)
import { useEffect, useEffectEvent, useState } from 'react';

export type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

type Settled<T> =
  | { url: string; status: 'success'; data: T }
  | { url: string; status: 'error'; error: Error };

export function useFetchJson<T>(
  url: string | null,
  parse: (json: unknown) => T,
): FetchState<T> {
  const [settled, setSettled] = useState<Settled<T> | null>(null);
  const parseLatest = useEffectEvent(parse); // always the latest parser, never a dependency

  useEffect(() => {
    if (url === null) return;
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
        return res.json() as Promise<unknown>;
      })
      .then((json) => {
        // The response can land in the same instant the cleanup aborts
        // (e.g. res.json() still parsing during a URL change). Without
        // this guard the stale success overwrites the newer settled
        // state and the hook sticks on 'loading' for the current URL.
        if (controller.signal.aborted) return;
        setSettled({ url, status: 'success', data: parseLatest(json) });
      })
      .catch((err: unknown) => {
        if (controller.signal.aborted) return; // we cancelled it - not an error
        setSettled({
          url,
          status: 'error',
          error: err instanceof Error ? err : new Error(String(err)),
        });
      });

    return () => controller.abort();
  }, [url]);

  // Derived state - no extra renders, no "loading" flicker bugs
  if (url === null) return { status: 'idle' };
  if (settled === null || settled.url !== url) return { status: 'loading' };
  // Return exactly the public FetchState shape - `settled` also carries the
  // internal `url` bookkeeping field, which must not leak to callers.
  return settled.status === 'success'
    ? { status: 'success', data: settled.data }
    : { status: 'error', error: settled.error };
}

Read the two guarded lines again: the if (controller.signal.aborted) return; in the success handler, and the shape-exact return at the bottom. Neither was in my first version of this hook. The next section explains what my tests did to it.

Usage stays honest about validation. The hook takes unknown JSON and a parser, because API payloads are guilty until proven innocent. I treat parsing as a contract check, the same habit I wrote about in Zod as a contract; with Zod, the parser is one line: useFetchJson(url, (json) => UsersSchema.parse(json)).

tsx
type User = { id: number; name: string };

function parseUsers(json: unknown): User[] {
  if (!Array.isArray(json)) throw new Error('Expected an array');
  return json.filter(
    (u): u is User => typeof u?.id === 'number' && typeof u?.name === 'string',
  );
}

function UserList() {
  const state = useFetchJson('/api/users', parseUsers);

  switch (state.status) {
    case 'idle':
    case 'loading': return <p>Loading...</p>;
    case 'error':   return <p role="alert">{state.error.message}</p>;
    case 'success': return <ul>{state.data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
    default: {
      const _exhaustive: never = state; // TS errors here if you add a status and forget it
      return _exhaustive;
    }
  }
}

The _exhaustive: never line is free insurance: add a fifth status next year and forget a branch, and the build breaks instead of your UI.

The race that survives

Here is the section I did not plan to write. Before publishing this post, I transcribed every hook into a repo, ran the type checker, the linter, the tutorial's own tests, and then a set of stress probes aimed at the claims I was making. The fetch hook's claim was the boldest: AbortController kills the race. Against a normal fetch stub, which respects the abort signal, the claim held. Then I wrote the probe that does not play fair: a fetch whose response for URL A resolves after the request for URL B has already settled, landing in the narrow window where the body has arrived but res.json() is still parsing while the URL changes underneath it.

The first version of useFetchJson failed that probe. The failure was not subtle:

  • The cleanup ran and aborted controller A, exactly as designed.
  • But the success handler had no aborted check. Only the .catch had one.
  • So A's late success called setSettled({ url: A, ... }) and overwrote B's fresh result.
  • The derived-state check then compared settled.url (A) against the current URL (B), concluded "still loading", and returned loading.
  • Nothing would ever fetch B again. The spinner was permanent. The test's verdict: expected 'loading' to be 'success'.

The fix is the one line you saw in B4: guard the success handler with the same if (controller.signal.aborted) return; as the catch. Symmetric guards on every handler that can write state, not just the error path. With it, all 15 tests pass, including the razor-edge probe.

The same probes caught a quieter second bug. The hook's declared return type was FetchState<T>, but the runtime object was the internal record, so callers actually received { status, data, url } with a bookkeeping field the types swore did not exist. TypeScript allowed it (structural typing, no excess-property check on a non-literal return), and only a deep-equal assertion in a probe exposed it. The fix returns exactly the public shape. Two lessons I now treat as rules:

  1. An abort guard on the error path only is a race with extra steps. Every continuation that can call a setter needs the guard.
  2. Types describe intent; tests inspect reality. If a shape matters to callers, assert the exact keys in a test. The compiler will not save you from your own internal types leaking.

If you take one idea from this post into your codebase, take the probe, not the hook: stub a fetch that ignores the abort signal, resolve the stale request last, and see what your data layer does.

B5 · useWindowEvent, typed events without the re-subscribe churn

ts
// hooks/useWindowEvent.ts   (React 19.2+)
import { useEffect, useEffectEvent } from 'react';

export function useWindowEvent<K extends keyof WindowEventMap>(
  type: K,
  handler: (event: WindowEventMap[K]) => void,
  { capture = false, passive }: { capture?: boolean; passive?: boolean } = {},
) {
  const onEvent = useEffectEvent(handler);

  useEffect(() => {
    const listener = (event: WindowEventMap[K]) => onEvent(event);
    // Only pass `passive` when the caller set it: writing `passive: undefined`
    // into the options object fails type-check under exactOptionalPropertyTypes.
    const options: AddEventListenerOptions = { capture };
    if (passive !== undefined) options.passive = passive;
    window.addEventListener(type, listener, options);
    return () => window.removeEventListener(type, listener, { capture });
  }, [type, capture, passive]);
}
tsx
useWindowEvent('keydown', (e) => {
  if (e.key === 'Escape') modal.close(); // `e` is a KeyboardEvent, inferred automatically
});

Without useEffectEvent, an inline handler is a new function on every render, so your listener detaches and re-attaches constantly. The Effect Event always sees the latest handler and is never a dependency, so the listener attaches once. The generic over WindowEventMap means TypeScript infers the event type from the string: pass 'keydown', get a KeyboardEvent, no annotations. And note the small options-object dance in the middle: splatting passive: undefined into the options fails type-checking under exactOptionalPropertyTypes, a strictness flag more codebases are switching on. Build the object, set the field only when the caller provided it.

B6 · useOnClickOutside, refs done right

ts
// hooks/useOnClickOutside.ts   (React 19.2+)
import { useEffect, useEffectEvent, type RefObject } from 'react';

export function useOnClickOutside<E extends HTMLElement>(
  ref: RefObject<E | null>,
  handler: (event: PointerEvent) => void,
) {
  const onOutside = useEffectEvent(handler);

  useEffect(() => {
    const listener = (event: PointerEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      onOutside(event);
    };
    document.addEventListener('pointerdown', listener);
    return () => document.removeEventListener('pointerdown', listener);
  }, [ref]);
}
tsx
const panelRef = useRef<HTMLDivElement>(null);
useOnClickOutside(panelRef, () => menu.close());
<div ref={panelRef}>...</div>

Same Effect Event pattern as B5, one new idea: the ref is read inside the listener, never during render. That is the whole discipline with refs in modern React, and it is why this hook survives the compiler-powered lint rules untouched. I listen on pointerdown rather than click so the close fires before focus shifts and swallows it.

B7 · useMediaQuery, the right tool for browser state

ts
// hooks/useMediaQuery.ts
import { useCallback, useSyncExternalStore } from 'react';

export function useMediaQuery(query: string, serverFallback = false): boolean {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    },
    [query],
  );

  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches, // client snapshot
    () => serverFallback,                    // SSR snapshot
  );
}
tsx
const isMobile = useMediaQuery('(max-width: 640px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

When the data lives outside React (viewport size, network status, storage), reach for useSyncExternalStore instead of bolting a listener onto useState and useEffect. It is React's purpose-built API for external sources: it avoids tearing under concurrent rendering, and the third argument gives you an honest SSR snapshot instead of a hydration mismatch. One environment warning from my own test runs: jsdom ships no matchMedia, so unit tests for this hook need a stub before they can exercise it. That is a test-setup fact, not a flaw in the hook.

B8 · useLocalStorage, cross-tab sync with no library

ts
// hooks/useLocalStorage.ts
import {
  useCallback, useMemo, useState, useSyncExternalStore, type SetStateAction,
} from 'react';

const SAME_TAB_EVENT = 'zv:local-storage';

function subscribe(onChange: () => void) {
  window.addEventListener('storage', onChange);       // fires for OTHER tabs
  window.addEventListener(SAME_TAB_EVENT, onChange);  // we fire this for THIS tab
  return () => {
    window.removeEventListener('storage', onChange);
    window.removeEventListener(SAME_TAB_EVENT, onChange);
  };
}

function safeParse<T>(raw: string | null, fallback: T): T {
  if (raw === null) return fallback;
  try { return JSON.parse(raw) as T; } catch { return fallback; }
}

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [fallback] = useState(initialValue); // freeze the first value → stable identity

  // Snapshot is the raw STRING: strings compare by value, so no infinite re-renders
  const raw = useSyncExternalStore(
    subscribe,
    () => window.localStorage.getItem(key),
    () => null, // server: always fallback
  );

  const value = useMemo(() => safeParse(raw, fallback), [raw, fallback]);

  const setValue = useCallback(
    (next: SetStateAction<T>) => {
      const current = safeParse(window.localStorage.getItem(key), fallback);
      const resolved =
        typeof next === 'function' ? (next as (prev: T) => T)(current) : next;
      try {
        window.localStorage.setItem(key, JSON.stringify(resolved));
      } catch (err) {
        console.warn(`useLocalStorage: could not write "${key}"`, err); // quota / private mode
        return;
      }
      window.dispatchEvent(new Event(SAME_TAB_EVENT));
    },
    [key, fallback],
  );

  const remove = useCallback(() => {
    window.localStorage.removeItem(key);
    window.dispatchEvent(new Event(SAME_TAB_EVENT));
  }, [key]);

  return [value, setValue, remove] as const;
}
tsx
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
<button onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}>Toggle</button>
// Open two tabs: change it in one and watch the other update

This is the strongest block in the kit, and the detail that makes it work is easy to miss: the snapshot is the raw string from getItem, not a parsed object. Strings compare by value, so React sees a stable snapshot and does not loop. Parse inside useMemo, after the store read. The browser's storage event only fires in other tabs, which is why the hook dispatches its own same-tab event after every write; together they keep two components on one key, and two tabs on one origin, in sync. My probes verified the unglamorous parts too: a stored value beats the initial value, corrupt JSON falls back without throwing, and remove() resets every subscriber. Two constraints to respect: T must be JSON-serializable, and it cannot be a function type. And yes, this hook does share state, because the store lives outside React. That is the exception that proves the rule from the top of this post.

B9 · useProductSearch, composition is the payoff

Everything above exists so this block can be eight lines long:

ts
// features/products/useProductSearch.ts
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFetchJson } from '@/hooks/useFetchJson';
import { parseProducts } from './api';

export function useProductSearch(query: string) {
  const debounced = useDebouncedValue(query.trim(), 300);
  const url =
    debounced.length >= 2
      ? `/api/products?q=${encodeURIComponent(debounced)}`
      : null;
  return useFetchJson(url, parseProducts);
}
ts
// features/products/api.ts - pure parser, no React
export type Product = { id: number; name: string; price: number };

export function parseProducts(json: unknown): Product[] {
  if (!Array.isArray(json)) throw new Error('Expected an array of products');
  return json.filter(
    (p): p is Product =>
      typeof p?.id === 'number' &&
      typeof p?.name === 'string' &&
      typeof p?.price === 'number',
  );
}
tsx
function SearchPage() {
  const [query, setQuery] = useState('');
  const results = useProductSearch(query);
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      {results.status === 'loading' && <Spinner />}
      {results.status === 'success' && <ProductGrid items={results.data} />}
    </>
  );
}

Read the component out loud: an input, a spinner while loading, a grid on success. It says what it does and nothing about how. Debounce, cancellation, parsing and derived loading are all inside the domain hook, which is the only thing the component imports. That is the whole architecture argument of this post in one example, and the testing section shows this composed hook passing end-to-end, alias wiring included.

Testing hooks with renderHook (B10)

A hook you cannot test is a hook you will eventually fear. renderHook from React Testing Library mounts your hook inside a tiny invisible component, so you can read its return value and drive it with act(). No browser, no rendered UI, tests in milliseconds.

bash
npm i -D vitest @testing-library/react jsdom
ts
// vitest.config.ts
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  resolve: {
    alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
  },
  test: { environment: 'jsdom' },
});

The resolve.alias block is load-bearing, and skipping it is the second setup trap in this post. B9 imports through @/hooks/.... TypeScript resolves that from tsconfig paths, but Vitest and Vite ignore tsconfig paths entirely, so the first test run for the composed hook dies with "Failed to resolve import" until you wire the alias here. (Your bundler needs the same alias for the app itself.)

ts
// hooks/__tests__/hooks.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useToggle } from '../useToggle';
import { useDebouncedValue } from '../useDebouncedValue';
import { useFetchJson } from '../useFetchJson';

afterEach(() => {
  vi.useRealTimers();
  vi.unstubAllGlobals();
});

describe('useToggle', () => {
  it('flips the value', () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current[0]).toBe(false);
    act(() => result.current[1]());
    expect(result.current[0]).toBe(true);
  });
});

describe('useDebouncedValue', () => {
  it('only updates after the delay', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ v }) => useDebouncedValue(v, 300),
      { initialProps: { v: 'a' } },
    );
    rerender({ v: 'ab' });
    expect(result.current).toBe('a');
    act(() => { vi.advanceTimersByTime(300); });
    expect(result.current).toBe('ab');
  });
});

describe('useFetchJson', () => {
  it('goes loading → success', async () => {
    vi.stubGlobal('fetch', vi.fn(async () =>
      new Response(JSON.stringify([1, 2, 3]), { status: 200 }),
    ));
    const { result } = renderHook(() =>
      useFetchJson('/api/nums', (j) => j as number[]),
    );
    expect(result.current.status).toBe('loading');
    await waitFor(() => expect(result.current.status).toBe('success'));
  });
});

One trap cost me a debugging session so it will not cost you one: waitFor polls on timers. Combine it with vi.useFakeTimers() and the poll can never advance, so the test hangs until timeout. Keep fake timers in the debounce test where they belong, and real timers anywhere waitFor appears, exactly as the suite above does. The repo's full run is 15 tests: these three, plus twelve stress probes covering the races, the storage sync, listener cleanup and the composed search hook. All green on React 19.3.1.

Seven anti-patterns I will keep rejecting

#Do notDo this instead
1useMount, useEffectOnce and other lifecycle wrapper hooksName hooks after concrete use cases (useChatRoom, useOnlineStatus). The React docs advise exactly this, and a lifecycle name tells the reader nothing about intent.
2Prefixing a hook-free function with useMake it a plain function in lib/. The prefix is a contract the linter enforces; lying about it corrupts the contract for everyone.
3Expecting two components to share state through the same hookHooks share logic. For shared state use context, an external store, or B8's storage pattern.
4useEffect(() => setFullName(first + ' ' + last))Derive it during render: const fullName = .... If it can be computed from props and state, it does not belong in an Effect or in state.
5Reading or writing ref.current during render (the classic hand-rolled usePrevious)Touch refs in Effects and event handlers only, as B6 does. Render must stay pure.
6Calling a hook conditionally (if (x) useFoo())Call it unconditionally and pass null to switch it off, the way B4 idles on a null URL.
7God hooks: one usePage() returning 30 fieldsSplit by concern, then compose, the way B9 composes B3 and B4. A hook with 30 fields is a component's worth of logic wearing a trench coat.

Architecture: hooks at scale

Nine good hooks in one folder is a toolkit. Ninety hooks with no structure is a swamp. The layout I use, and the one the repo follows:

ts
src/
  hooks/                    # generic, app-agnostic (B1-B8) - could become a package
    useToggle.ts
    useDebouncedValue.ts
    __tests__/
  features/
    products/
      api.ts                # fetchers + parsers (pure, no React)
      useProductSearch.ts   # domain hook (B9) - the only thing UI imports
      ProductSearch.tsx
  lib/                      # pure utils (no hooks, no use prefix)

Three layers, one direction of dependency: components -> domain hooks -> generic hooks -> pure lib. Generic hooks know nothing about your business. Domain hooks hide which data library or endpoint sits underneath; when I swapped a fetch layer for a framework data layer in a Next.js app (the same layering I lean on in my Cache Components migration guide), the components did not change, because they never imported the engine. Components only render.

The checklist I hold a hooks folder to:

  • Every hook has an explicit return type on its public API.
  • Every subscription, timer and request has a cleanup path.
  • The SSR story is written down (getServerSnapshot, no window at module top level).
  • Every hook has a renderHook test; async shapes assert exact keys, not just status.
  • A TSDoc comment with a usage example, because it renders in IntelliSense where your teammates actually look.
  • When a third app copies the same hook, it graduates to an internal package. Copying is the signal; extracting early is ceremony.

My verdict

Copy the kit as a starting point, not as gospel. The two habits worth stealing even if you never paste a line are derived loading state and symmetric abort guards, because both delete bug classes rather than individual bugs. Keep the hand-rolled fetch hook for learning and small, controlled cases; the moment you need caching, retries or refetch-on-focus, put TanStack Query behind a domain hook and keep your components exactly as they are. And test the abort-too-late window in whatever data layer you run today. If a stale response can still write state in your app, this post just paid for itself.

FAQ

What is a custom hook in React?

A function whose name starts with use and that calls other hooks. It lets you move stateful logic (fetching, timers, subscriptions, storage) out of a component so the component describes what it renders instead of how the logic works. If your function calls no hooks, it is a plain function and should not carry the use prefix.

Do custom hooks share state between components?

No. A hook shares logic, not state. Every component that calls useToggle gets its own independent state. When you genuinely need shared state, put it outside React: context for app-wide values, or an external store read with useSyncExternalStore, which is how B8 keeps every tab in sync.

Should I use the useFetchJson hook or TanStack Query in production?

TanStack Query for real server state: caching, retries, refetch-on-focus and request deduping are a lot of code to own. I use the hand-rolled hook to teach the pattern and for small, controlled cases. Either way, wrap it in a domain hook like useUsers so your components never import the fetch layer or the library directly.

What is useEffectEvent and when should I use it?

useEffectEvent (React 19.2 and later) splits the event-like part of an Effect out of the Effect itself. The returned function always sees the latest props and state, and it is never listed as a dependency, so an inline callback stops your listener or fetch from re-running on every render. Only call it from inside Effects, and never during render.

How do I test a custom hook?

With renderHook from @testing-library/react plus Vitest. renderHook mounts your hook inside a tiny test component so you can read its return value and drive it with act(). Stub fetch for request hooks, and use fake timers for debounce-style hooks, but never combine waitFor with fake timers: waitFor polls on timers and will time out. The B10 section above is a complete working setup.

Why does my ESLint react-hooks config lint nothing in a TypeScript project?

Because the recommended flat config has no files field, and ESLint only lints extensions that some config block covers. On its own, every .ts file is silently ignored, and ESLint's default parser cannot parse TypeScript syntax anyway. Add a block with files covering ts/tsx and @typescript-eslint/parser as the parser, as shown in the linter section of this post.

Get the code

The full kit, with the probes that caught the race, lives at github.com/zainmustafai/react-hooks-kit: npm run typecheck, npm test and npm run lint are the three commands that keep it honest. The one-page cheat sheet is the version to pin next to your monitor. Your move: build useIntersectionObserver<E extends Element>() returning [ref, isVisible], and open a PR or post it with #zainvibesHooks. I read every one.

I build React and TypeScript systems like this for clients. If your codebase has a 200-line component problem of its own, talk to me.