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.
6.4 KiB
paths
| paths | ||||||
|---|---|---|---|---|---|---|
|
React Hooks
This file covers React hooks (
useState,useEffect,useMemo,useCallback, custom hooks) — NOT the Claude Codehooks/runtime system. Naming matches the per-language conventionrules/<lang>/hooks.mdused 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.
- Hooks only at the top level of a function component or another hook
- Never in loops, conditionals, nested functions, or after early returns
- Always called in the same order on every render
- 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
keyon 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-depslint 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
useCallbackonly 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:
- The value is passed to a
React.memo-wrapped child as a prop, and identity matters - The value is a dependency of another
useEffect/useMemo/useCallback - 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
useStatewith 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)— neversetCount(count + 1)inside async or batched contexts - Group related state into one object only when they always change together; otherwise split into multiple
useStatecalls - Use
useReduceronce 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.currentduring render — only inside effects or event handlers useImperativeHandleonly 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()(oruseActionState) — form submission state without prop drillinguseOptimistic()— optimistic UI updates while a server action is pendinguseTransition()— 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:
- Using the functional updater form of
setState - Putting the changing value in the dep array of
useEffectand rebuilding the handler - 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.