rules/react/hooks.md
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.
Enforce eslint-plugin-react-hooks with react-hooks/rules-of-hooks set to error.
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 UseuseEffect is for synchronizing with external systems (subscriptions, browser APIs, third-party libraries). It is not the right tool for:
key on the parent or derive from propsmain.tsx// WRONG: effect for derived state
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
// CORRECT: derive during render
const fullName = `${first} ${last}`;
react-hooks/exhaustive-deps lint rule — never silence it without a comment explaining whyuseCallback only when the function is itself a dependency of another hook or passed to a memoized childEvery 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 ItDefault position: do not memoize. Add useMemo / useCallback only when:
React.memo-wrapped child as a prop, and identity mattersuseEffect / useMemo / useCallbackPremature memoization adds noise, hides bugs, and can be slower than the recompute it replaces.
Extract a custom hook when:
useDebounce, useOnClickOutside, useLocalStorage)Do NOT extract when:
useState with a different name — adds indirection, no valueexport 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 PatternsuseState(() => computeInitial(prop)) when computation is expensivesetCount(c => c + 1) — never setCount(count + 1) inside async or batched contextsuseState callsuseReducer once state transitions are conditional on the previous state or there are 3+ related valuesuseRef Patternsref.current during render — only inside effects or event handlersuseImperativeHandle only when exposing a child API to a parent ref — last-resort escape hatchuseSyncExternalStoreUse 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,
);
use() — unwrap promises and contexts inline; usable conditionally (only hook with that property)useFormStatus() / useFormState() (or useActionState) — form submission state without prop drillinguseOptimistic() — optimistic UI updates while a server action is pendinguseTransition() — mark non-urgent state updates so urgent ones stay responsiveWhen the project targets React 19+, prefer these over hand-rolled equivalents.
Async handlers and intervals capture the values from the render where they were created. Fix by:
setStateuseEffect and rebuilding the handlerRequired rules:
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Treat exhaustive-deps warnings as errors in CI for new code.