Status: Draft (pending discussion) Korean version: ko/hook-design-principles.md
Hook design philosophy accumulated from operating react-simplikit is defined as a single set of shared principles. These principles serve two purposes:
- Code review —
react-hook-reviewskill provides feedback based on these principles - Code writing —
react-hook-writingskill provides guidance based on these principles
| Direction | Source | Scope |
|---|---|---|
| Coding Principles (Section 2) | CLAUDE.md, AGENTS.md, internal skills | Return values, TypeScript, performance, documentation |
| Usage Patterns (Section 3) | React official docs (react.dev) | State design, effect usage, memoization, custom hook design |
| # | Requirement | Detail |
|---|---|---|
| R1 | Shared principles for review/writing | Both skills reference the same principles |
| R2 | Why-first | Not just rules (What), but philosophy (Why) with narrative explanation |
| R3 | Opinionated transparency | Clearly mark 🟢 Best Practice vs 🟡 Opinionated |
| R4 | Project-agnostic | No react-simplikit paths/commands/utils — universal principles only |
| R5 | Cross-tool | Claude Code plugin + Codex (AGENTS.md) + Cursor (.cursorrules) |
| # | Question | Options |
|---|---|---|
| Q1 | Include C14 (Named useEffect)? | A) Include as "Recommended" B) Exclude |
| Q2 | Recommend C2 (SSR-Safe) for non-SSR projects? | A) Always B) SSR projects only |
| Q3 | Require @example in C9 (JSDoc)? | A) All 4 tags required B) @example is recommended |
| Q4 | Any additional principles? | — |
| Q5 | Finalize principles first, or go straight to plugin structure? | A) Principles first B) Plugin directly |
| Q6 | Plugin distribution channel | A) git-subdir B) npm C) TBD |
Coding style principles extracted from CLAUDE.md + AGENTS.md + internal skills.
C1 and C7 are marked 🟡 inline because they are project conventions rather than React-wide best practices. C14 is listed in its own 🟡 section below.
Return objects even for single values — { value } form. Objects are order-independent, self-documenting via named fields, and extensible without breaking changes.
Note: This is a project convention. React docs say "Hooks may return arbitrary values." React's own
useStatereturns a tuple. We chose objects for extensibility. 📖 react.dev — Custom Hooks
function useDebounce<T>(value: T, delay: number): { value: T };
function useToggle(init: boolean): { value: boolean; toggle: () => void };
function usePagination(): { page: number; next: () => void; prev: () => void };useState(FIXED_VALUE) + useEffect(sync). Never initialize state with browser APIs. Server has no window — crashes or hydration mismatch.
// ✅ SSR safe
const [width, setWidth] = useState(0);
useEffect(function syncWidth() {
setWidth(window.innerWidth);
}, []);
// ❌ SSR crash
const [width, setWidth] = useState(window.innerWidth);
// ⚠️ Acceptable in client-only apps
const [width, setWidth] = useState(() => {
if (typeof window === 'undefined') return 0;
return window.innerWidth;
});Return cleanup when your effect sets up subscriptions, listeners, timers, or ongoing connections. React docs: cleanup is optional, not required for every effect — but mandatory when synchronizing with external systems.
📖 react.dev — useEffect > "Your setup function may also optionally return a cleanup function."
// Event listeners
useEffect(function subscribe() {
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
// AbortController (async)
useEffect(
function fetchData() {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(/* ... */);
return () => controller.abort();
},
[url]
);
// Timers
useEffect(function tick() {
const id = setInterval(callback, 1000);
return () => clearInterval(id);
}, []);Use generics <T>. any propagates and defeats the type system. Justified eslint-disable with comment is acceptable for generic callback types.
// ✅ Generic
function useDebounce<T>(value: T, delay: number): T;
// ✅ Justified exception (comment required)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic callback
type AnyFunction = (...args: any[]) => unknown;Guarantees tree-shaking + unambiguous imports. No export default.
No implicit if (value) — prevents silent bugs with 0, "", false. Use == null for nullish checks (both null and undefined).
if (ref == null) { return; } // ✅ null + undefined
const controlled = valueProp !== undefined; // ✅ when distinction needed
if (count) { ... } // ❌ fails when count = 0Hook params as object props, not positional args. Order-independent, self-documenting, extensible without breaking changes.
Note: This is a project convention. React's own hooks use positional args (
useState(initialValue)). We chose objects for extensibility and self-documentation.
// ✅ Object params
function useDebounce<T>({
value,
delay,
leading,
}: {
value: T;
delay: number;
leading?: boolean;
}): { value: T };
// ❌ Positional params
function useDebounce<T>(
value: T,
delay: number,
leading?: boolean
): { value: T };Early return over nested if-else. Filter failure conditions first, keep success logic flat.
// ✅
function process(value: string | null) {
if (value == null) {
return DEFAULT;
}
return transform(value);
}
// ❌
function process(value: string | null) {
if (value != null) {
return transform(value);
} else {
return DEFAULT;
}
}All public APIs must have @description + @param + @returns + @example. Enables AI doc generation + IDE tooltips.
/**
* @description Delays value updates until after a specified period of inactivity.
* @param value - The value to debounce
* @param delay - Delay in milliseconds
* @returns The debounced value
* @example
* const debouncedQuery = useDebounce(query, 300);
*/Apply only to high-frequency events (30+/sec). Not needed for general hooks.
| Technique | When to Apply |
|---|---|
| Throttle (16ms) | scroll, resize, pointer, keyboard |
| Deduplicate | Skip setState when value unchanged |
| startTransition | Non-urgent derived computations (React 18+) |
Use function keyword for declarations. Arrows only for inline callbacks (map, filter).
function toggle(state: boolean) {
return !state;
} // ✅ declaration
items.filter(item => item != null); // ✅ inline
const toggle = (state: boolean) => !state; // ❌ arrow for declarationNo external runtime dependencies in production code. Only peerDependencies allowed. Minimizes bundle size + prevents dependency conflicts.
Inject external dependencies as parameters rather than importing directly inside hooks. Improves testability + replaceability.
// ✅ Dependency injection
function useFetch<T>(fetcher: (url: string) => Promise<T>, url: string) { ... }
// ❌ Direct import
function useFetch<T>(url: string) { const res = await axios.get(url); ... }useEffect(function handleResize() {...}). Shows "handleResize" instead of "anonymous" in error stacks. Trade-off: more verbose than arrows. Named cleanup is "Recommended" (not required).
| Item | Reason |
|---|---|
| Import extensions (.js/.ts) | Build-tool dependent |
| 100% test coverage | Project policy |
| File structure / commit conventions | Not hook design philosophy |
Separate document: react-hook-usage-patterns.md
17 patterns based on React official docs (react.dev), with source URLs and quotes (U1-U18, U4 removed):
| Category | Count | Key Patterns |
|---|---|---|
| State Design | U1-U3, U5-U7 (6) | Derive don't sync, don't mirror props, useRef, discriminated unions, group state |
| Effect Usage | U8-U14 | Effects for sync only, no chains, key reset, async cleanup |
| Memoization | U15-U16 | useMemo >= 1ms, useCallback + memo() only |
| Hook Design | U17 | No lifecycle wrappers, extract reusable stateful logic only |
| Identity and Rendering | U18 | Stable keys, intentional remounts, rendering efficiency |
This document (principles definition)
↓ compress
react-hook-review/SKILL.md (checklist)
react-hook-writing/SKILL.md (guide)
↓ further compress
AGENTS.md Part 1 (for Codex)
↓ reference
.cursorrules (for Cursor)
packages/plugin/ (planned)
├── .claude-plugin/plugin.json
├── .codex-plugin/plugin.json
├── principles/ ← Shared principles single source
├── skills/
│ ├── react-hook-review/SKILL.md ← C1-C14 + U1-U18 checklist
│ ├── react-hook-review/references/key-rendering.md
│ └── react-hook-writing/
│ ├── SKILL.md ← Themed guide
│ └── references/patterns.md ← 3 hook implementations
└── README.md
| Tool | File | Current | Planned |
|---|---|---|---|
| Claude Code (internal) | .claude/skills/ |
✅ 10 skills | Keep |
| Claude Code (plugin) | packages/plugin/ |
❌ | Create via Phase 1-5 |
| Codex | AGENTS.md |
✅ 162 lines | Split into Part 1 (Universal) + Part 2 (Project) |
| Cursor | .cursorrules |
✅ 28 lines | Keep AGENTS.md reference |
| Extracted (Philosophy) | Left Behind (Implementation) |
|---|---|
| "Always return objects" | packages/core/src/hooks/ paths |
| "Named useEffect improves stack traces" | yarn test, yarn fix commands |
| "SSR-safe: fixed initial + useEffect sync" | renderHookSSR.serverOnly() utility |
| "4 JSDoc tags for AI doc generation" | 100% coverage threshold |
| Before (Project-Specific) | After (Universal) |
|---|---|
renderHookSSR.serverOnly() |
Vitest + delete global.window |
yarn test / yarn fix |
"Run your test suite" |
packages/core/ paths |
"your source directory" |
react-simplikit references |
Removed |
| Phase | Content | Output |
|---|---|---|
| 1 | Directory + plugin.json + README | packages/plugin/ structure |
| 2 | react-hook-review SKILL.md | C1-C14 + U1-U18 checklist |
| 3 | react-hook-writing SKILL.md + patterns.md | Themed guide + 3 hook examples |
| 4 | Generalization validation (grep) | 0 project references |
| 5 | Plugin validate + local test | Working confirmation |
| Item | Pass Criteria |
|---|---|
| Plugin structure | claude plugin validate . — 0 errors |
| Universality | 0 project-specific references in another React project |
| Philosophy depth | Every rule has narrative "Why" |
| Opinionated transparency | 🟡 items have trade-offs stated |
- Codex/Gemini support (via AGENTS.md Part 1)
- Component design philosophy
- Marketplace migration (when 3+ plugins)