Files
everything-claude-code/rules/react/hooks.md
T
AlexisLeDain 1f0486b8d1 feat(rules): add rules/react/ track
Five rule files mirroring per-language convention (coding-style,
hooks, patterns, security, testing). Each has `paths:` glob
frontmatter for auto-activation when editing matching files.

- coding-style.md: file extensions, naming, JSX, RSC boundary
- hooks.md: React hooks (NOT Claude Code hooks) — rules-of-hooks,
  dep arrays, cleanup, memoization, React 19 additions
- patterns.md: container/presentational split, state location
  decision tree, Suspense + error boundaries, forms, data fetching
- security.md: dangerouslySetInnerHTML, unsafe URL schemes,
  server-action validation, env-var leaks, CSP
- testing.md: RTL queries, userEvent, async, MSW, axe, anti-patterns

Each file extends typescript/* and common/* rules.
2026-05-20 09:52:24 +02:00

6.4 KiB

paths
paths
**/*.tsx
**/*.jsx
**/hooks/**/*.ts
**/hooks/**/*.js
**/use-*.ts
**/use-*.tsx

React Hooks

This file covers React hooks (useState, useEffect, useMemo, useCallback, custom hooks) — NOT the Claude Code hooks/ runtime system. Naming matches the per-language convention rules/<lang>/hooks.md used across this repo.

Extends typescript/patterns.md and common/patterns.md.

Rules of Hooks

Enforce eslint-plugin-react-hooks with react-hooks/rules-of-hooks set to error.

  1. Hooks only at the top level of a function component or another hook
  2. Never in loops, conditionals, nested functions, or after early returns
  3. Always called in the same order on every render
  4. Only inside React function components or custom hooks (functions starting with use)
// WRONG: conditional hook
function Foo({ enabled }: { enabled: boolean }) {
  if (enabled) {
    const [x, setX] = useState(0); // rule violation
  }
}

// CORRECT: hook unconditional, condition inside
function Foo({ enabled }: { enabled: boolean }) {
  const [x, setX] = useState(0);
  if (!enabled) return null;
  return <span>{x}</span>;
}

useEffect — When NOT to Use

useEffect is for synchronizing with external systems (subscriptions, browser APIs, third-party libraries). It is not the right tool for:

  • Derived state — compute it during render
  • Transforming data for rendering — compute it during render
  • Resetting state when a prop changes — use a key on the parent or derive from props
  • Notifying parents of state changes — call the callback in the event handler
  • Initializing app-level singletons — call the function module-side or in main.tsx
// WRONG: effect for derived state
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${first} ${last}`);
}, [first, last]);

// CORRECT: derive during render
const fullName = `${first} ${last}`;

Dependency Arrays

  • Always include every reactive value referenced inside the effect/callback
  • Enable react-hooks/exhaustive-deps lint rule — never silence it without a comment explaining why
  • If the dep array grows unwieldy, the effect is doing too much — split it
  • Stable identity for functions passed in deps: wrap in useCallback only when the function is itself a dependency of another hook or passed to a memoized child

Cleanup

Every subscription, interval, listener, or in-flight request must clean up.

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal }).then(handleResponse);
  return () => controller.abort();
}, [url]);
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

Missing cleanup = race conditions when deps change, memory leaks on unmount.

useMemo and useCallback — When Worth It

Default position: do not memoize. Add useMemo / useCallback only when:

  1. The value is passed to a React.memo-wrapped child as a prop, and identity matters
  2. The value is a dependency of another useEffect / useMemo / useCallback
  3. The computation is measurably expensive (profile before assuming)

Premature memoization adds noise, hides bugs, and can be slower than the recompute it replaces.

Custom Hooks

Extract a custom hook when:

  • The same hook sequence (state + effect + computed) appears in 2+ components
  • The logic has a clear, nameable purpose (useDebounce, useOnClickOutside, useLocalStorage)
  • You want to test the logic independently of any component

Do NOT extract when:

  • It would have a single caller — inline it
  • The "hook" is just useState with a different name — adds indirection, no value
export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

useState Patterns

  • Initial state from prop only at mount: pass a function useState(() => computeInitial(prop)) when computation is expensive
  • Functional updater when the new state depends on the old: setCount(c => c + 1) — never setCount(count + 1) inside async or batched contexts
  • Group related state into one object only when they always change together; otherwise split into multiple useState calls
  • Use useReducer once state transitions are conditional on the previous state or there are 3+ related values

useRef Patterns

  • DOM refs for imperative APIs (focus, scroll, third-party libs)
  • Mutable container that does not trigger re-render (timer ids, previous values, "is mounted" flags)
  • Never read or write ref.current during render — only inside effects or event handlers
  • useImperativeHandle only when exposing a child API to a parent ref — last-resort escape hatch

useSyncExternalStore

Use this hook to subscribe to any external store (browser API, third-party state lib, custom event emitter). It is the supported way to make external state safe with concurrent rendering.

const isOnline = useSyncExternalStore(
  (cb) => {
    window.addEventListener("online", cb);
    window.addEventListener("offline", cb);
    return () => {
      window.removeEventListener("online", cb);
      window.removeEventListener("offline", cb);
    };
  },
  () => navigator.onLine,
  () => true,
);

React 19 Additions

  • use() — unwrap promises and contexts inline; usable conditionally (only hook with that property)
  • useFormStatus() / useFormState() (or useActionState) — form submission state without prop drilling
  • useOptimistic() — optimistic UI updates while a server action is pending
  • useTransition() — mark non-urgent state updates so urgent ones stay responsive

When the project targets React 19+, prefer these over hand-rolled equivalents.

Stale Closure Trap

Async handlers and intervals capture the values from the render where they were created. Fix by:

  1. Using the functional updater form of setState
  2. Putting the changing value in the dep array of useEffect and rebuilding the handler
  3. Reading from a ref that is kept in sync

Lint Configuration

Required rules:

{
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

Treat exhaustive-deps warnings as errors in CI for new code.