| name | react-hooks |
| description | React hooks and Context conventions for this project. Covers custom hook extraction rules, Context splitting (state vs actions), dependency management with biome useExhaustiveDependencies, and ref/callback patterns. Use when writing or reviewing custom hooks, Context providers, or component re-render optimization. |
React Hooks Best Practices
Overview
Guidelines for writing custom hooks, choosing the right hook, and avoiding common pitfalls. Optimized for React 19 + biome useExhaustiveDependencies.
When to Use
- Creating or modifying custom hooks
- Deciding whether to use
useEffect, useMemo, useCallback
- Debugging stale closures, infinite loops, or unnecessary re-renders
- Fixing biome/eslint exhaustive-deps warnings
Custom Hook Design
When to Extract a Custom Hook
Rule: only extract when there is actual duplication. Two or more components must share the same stateful logic before a hook is worth creating. A single consumer → keep the logic inline. Extracting a hook "for organization" with only one caller is over-engineering.
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;
}
Naming & Return Values
- Always prefix with
use
- Return object for 3+ values, tuple for 1-2 values
- Return stable references: actions via
useCallback, derived data via useMemo
function useToggle(initial = false): [boolean, () => void] { ... }
function useChat(socket: TypedSocket) {
return { sendMessage, abort, createSession };
}
Composition Over Complexity
Build complex hooks by composing simpler ones. Each hook should have a single responsibility.
function useChatPanel(socket: TypedSocket) {
const chat = useChat(socket);
const store = useChannel((s) => ({ messages: s.messages, status: s.status }));
return { ...chat, ...store };
}
You Might Not Need an Effect
Most useEffect calls fall into two categories — only one needs useEffect.
Don't Need useEffect
| Pattern | Instead |
|---|
| Transform data for render | Compute during render (const x = derive(props)) |
| Cache expensive computation | useMemo(() => compute(deps), [deps]) |
| Reset state when prop changes | Use key prop on component |
| Respond to user event | Handle in event handler directly |
| POST on form submit | Handle in onSubmit, not in effect |
| Initialize app once | Module-level code or useRef guard |
const [filtered, setFiltered] = useState(items);
useEffect(() => {
setFiltered(items.filter(predicate));
}, [items]);
const filtered = useMemo(() => items.filter(predicate), [items]);
Do Need useEffect
- Synchronize with external systems (DOM APIs, WebSocket, timers, third-party libs)
- Subscribe/unsubscribe to events (always return cleanup)
useEffect(() => {
socket.on('chat:event', onEvent);
return () => { socket.off('chat:event', onEvent); };
}, [socket]);
Context Best Practices — Split State and Actions
Two-Context Pattern (Required for Complex Providers)
When a context has both state (changes frequently) and actions (stable functions), split them into separate contexts. This prevents components that only call actions from re-rendering when state changes.
const StateContext = createContext<State | null>(null);
const ActionsContext = createContext<Actions | null>(null);
function Provider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
const actions = useMemo(() => ({
sendMessage: (msg: string) => dispatch({ type: 'SEND', msg }),
abort: () => dispatch({ type: 'ABORT' }),
}), [dispatch]);
return (
<StateContext.Provider value={state}>
<ActionsContext.Provider value={actions}>
{children}
</ActionsContext.Provider>
</StateContext.Provider>
);
}
function SendButton() {
const { sendMessage } = useActions();
return <button onClick={() => sendMessage('hi')}>Send</button>;
}
function MessageList() {
const { messages } = useState();
return <ul>{messages.map(m => <li key={m.id}>{m.content}</li>)}</ul>;
}
When NOT to Split
- Simple contexts with <5 fields and few consumers
- Contexts where every consumer needs both state and actions
- Prototyping / early development (split when performance matters)
UI state vs Data state
UI-only state (toggle 開關、dialog open、hover) 獨立放 UI context,不混進 data context。Data context 改動觸發的 re-render 不該影響「我剛打開一個 dropdown」這種純 UI 狀態。
Component 職責
- Socket / RPC 呼叫走 Context action(handler),component 只呼叫 action — 不在 component 直接
socket.emit(...)
- Component 讀多個 context 只是為了把 props 橋接給子 component → 讓子 component 自己讀 context,父不必中介
Bind class methods before passing through Context
Class methods lose this binding when destructured. If a handler is a class instance, wrap its methods with .bind() or arrow functions in useMemo before putting them on the context value:
<Context.Provider value={handler}>
const actions = useMemo(() => ({
send: handler.send.bind(handler),
abort: handler.abort.bind(handler),
}), [handler]);
useReducer dispatch is Stable
useReducer returns a stable dispatch — React guarantees it never changes. No need for useCallback or useMemo around dispatch.
const [state, dispatch] = useReducer(reducer, init);
Context Value Stability
Provider value 可以直接寫 inline — Compiler 會 memo。詳見 react-compiler skill。
<Ctx.Provider value={{ count, increment }}> {}
useCallback / useMemo / React.memo
This project uses React Compiler. 大部分情境 Compiler 會自動 memo,不用手動加。但有 3 個架構邊界必須手動處理(component React.memo、prop capture 意圖、高頻 setState commit)— 完整規則見 react-compiler skill。
Refs、dependency arrays 與 biome useExhaustiveDependencies
biome 的 useExhaustiveDependencies 規則、component-scoped function 的 false positive 修法、useChannel.getState() 在 callback 裡的用法見 references/biome-deps-and-refs.md。快速結論:useRef 值、useState setter、useReducer dispatch、useTransition startTransition 這四類是 stable,其他都視為 dep。
Testing Custom Hooks
Always use @testing-library/react as the first choice — either renderHook for isolated hook testing, or render + screen when testing the hook through a component. Do not call hook functions directly, do not mock React internals.
Use renderHook from @testing-library/react:
import { renderHook, act } from '@testing-library/react';
it('toggles value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => { result.current[1](); });
expect(result.current[0]).toBe(true);
});
For hooks with external dependencies (socket, API), inject via parameters — avoid vi.mock when possible.
Quick Reference
| Rule | Rationale |
|---|
Prefix with use | React's hook detection relies on naming |
| No conditional hooks | Hooks must be called in same order every render |
| Cleanup in useEffect | Prevent memory leaks, stale subscriptions |
| Derive during render | Avoids unnecessary state + effect cycles |
| Module-level helpers | Avoids biome exhaustive-deps false positives |
useRef for mutable flags | Stable identity, no re-renders, biome-safe |
getState() for store reads in callbacks | No subscription, always fresh, no deps needed |
Common Mistakes
| Mistake | Fix |
|---|
useEffect + setState for derived data | Compute during render or useMemo |
| Missing cleanup in useEffect | Always return cleanup for subscriptions/timers |
| Component-scoped helper in dep array | Move to module level or inline |
Wrapping every function in useCallback | Only when passed to memoized children or in deps |
ref.current in dep array | Refs are mutable — biome ignores them; use event-based updates |
| Effect runs on every render | Check dependency array; empty [] for mount-only |
相關 skill
- Context 測試 / socket 互動 →
fake-summoner-client
- Hook / component 測試 RTL 慣例 →
frontend-testing / testing-best-practices
- Store pattern(zustand 當 state 替代)→
zustand-state
- Tailwind class + axis 影響 render →
tailwind-v4