diff --git a/docs/hook-design-principles.md b/docs/hook-design-principles.md new file mode 100644 index 00000000..4892b87a --- /dev/null +++ b/docs/hook-design-principles.md @@ -0,0 +1,340 @@ +# React Hook Design Principles + +> Last Updated: 2026-04-07 +> Status: Draft (pending discussion) +> Korean version: [ko/hook-design-principles.md](./ko/hook-design-principles.md) + +--- + +## 1. Requirements + +### Background + +Hook design philosophy accumulated from operating react-simplikit is defined as **a single set of shared principles**. These principles serve two purposes: + +1. **Code review** โ€” `react-hook-review` skill provides feedback based on these principles +2. **Code writing** โ€” `react-hook-writing` skill provides guidance based on these principles + +### Two Directions of 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 | + +### Core Requirements + +| # | 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) | + +### Open Questions + +| # | 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 | + +--- + +## 2. Hook Coding Principles (Direction 1) + +Coding style principles extracted from CLAUDE.md + AGENTS.md + internal skills. + +### ๐ŸŸข Best Practice (13) + +#### C1. Always Return Objects ๐ŸŸก + +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 `useState` returns a tuple. We chose objects for extensibility. +> ๐Ÿ“– [react.dev โ€” Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) + +```ts +function useDebounce(value: T, delay: number): { value: T } +function useToggle(init: boolean): { value: boolean; toggle: () => void } +function usePagination(): { page: number; next: () => void; prev: () => void } +``` + +#### C2. SSR-Safe Initialization + +`useState(FIXED_VALUE)` + `useEffect(sync)`. Never initialize state with browser APIs. Server has no `window` โ€” crashes or hydration mismatch. + +> ๐Ÿ“– [react.dev โ€” hydrateRoot](https://react.dev/reference/react-dom/client/hydrateRoot) + +```ts +// โœ… 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; +}); +``` + +#### C3. useEffect Cleanup When Subscribing + +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](https://react.dev/reference/react/useEffect) +> *"Your setup function may also optionally return a cleanup function."* + +```ts +// 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); +}, []); +``` + +#### C4. No `any` Types + +Use generics ``. `any` propagates and defeats the type system. Justified `eslint-disable` with comment is acceptable for generic callback types. + +```ts +// โœ… Generic +function useDebounce(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; +``` + +#### C5. Named Exports Only + +Guarantees tree-shaking + unambiguous imports. No `export default`. + +#### C6. Strict Boolean & Nullish Checks + +No implicit `if (value)` โ€” prevents silent bugs with `0`, `""`, `false`. Use `== null` for nullish checks (both null and undefined). + +```ts +if (ref == null) { return; } // โœ… null + undefined +const controlled = valueProp !== undefined; // โœ… when distinction needed +if (count) { ... } // โŒ fails when count = 0 +``` + +#### C7. Object Parameters ๐ŸŸก + +Hook 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. + +```ts +// โœ… Object params +function useDebounce({ value, delay, leading }: { + value: T; delay: number; leading?: boolean; +}): { value: T } + +// โŒ Positional params +function useDebounce(value: T, delay: number, leading?: boolean): { value: T } +``` + +#### C8. Guard Clauses (Early Return) + +Early return over nested if-else. Filter failure conditions first, keep success logic flat. + +```ts +// โœ… +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; } +} +``` + +#### C9. JSDoc 4-Tag + +All public APIs must have `@description` + `@param` + `@returns` + `@example`. Enables AI doc generation + IDE tooltips. + +```ts +/** + * @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); + */ +``` + +#### C10. Performance Patterns + +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+) | + +#### C11. Function Keyword for Declarations + +Use `function` keyword for declarations. Arrows only for inline callbacks (map, filter). + +```ts +function toggle(state: boolean) { return !state; } // โœ… declaration +items.filter(item => item != null); // โœ… inline +const toggle = (state: boolean) => !state; // โŒ arrow for declaration +``` + +#### C12. Zero Runtime Dependencies + +No external runtime dependencies in production code. Only `peerDependencies` allowed. Minimizes bundle size + prevents dependency conflicts. + +#### C13. Avoid Direct External Dependencies + +Inject external dependencies as parameters rather than importing directly inside hooks. Improves testability + replaceability. + +```ts +// โœ… Dependency injection +function useFetch(fetcher: (url: string) => Promise, url: string) { ... } + +// โŒ Direct import +function useFetch(url: string) { const res = await axios.get(url); ... } +``` + +### ๐ŸŸก Opinionated (1) + +#### C14. Named useEffect Functions + +`useEffect(function handleResize() {...})`. Shows "handleResize" instead of "anonymous" in error stacks. Trade-off: more verbose than arrows. Named cleanup is "Recommended" (not required). + +### Excluded (Project-Specific Decisions) + +| Item | Reason | +|------|--------| +| Import extensions (.js/.ts) | Build-tool dependent | +| 100% test coverage | Project policy | +| File structure / commit conventions | Not hook design philosophy | + +--- + +## 3. Hook Usage Patterns (Direction 2) + +> Separate document: [react-hook-usage-patterns.md](./react-hook-usage-patterns.md) + +17 patterns based on React official docs (react.dev), with source URLs and quotes (U1-U17): + +| Category | Count | Key Patterns | +|----------|-------|-------------| +| State Design | U1-U7 | 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 | + +--- + +## 4. Plugin Architecture + +### Derivation Flow + +``` +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) +``` + +### Directory Structure + +``` +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-U17 checklist +โ”‚ โ””โ”€โ”€ react-hook-writing/ +โ”‚ โ”œโ”€โ”€ SKILL.md โ† Themed guide +โ”‚ โ””โ”€โ”€ references/patterns.md โ† 3 hook implementations +โ””โ”€โ”€ README.md +``` + +### Cross-Tool Support + +| 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 | + +### Extraction Rules + +| 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 | + +### Generalization Transforms + +| 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 | + +--- + +## 5. Execution Roadmap + +| Phase | Content | Output | +|-------|---------|--------| +| 1 | Directory + plugin.json + README | `packages/plugin/` structure | +| 2 | react-hook-review SKILL.md | C1-C14 + U1-U17 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 | + +### Validation Criteria + +| 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 | + +### Future Expansion + +- Codex/Gemini support (via AGENTS.md Part 1) +- Component design philosophy +- Marketplace migration (when 3+ plugins) diff --git a/docs/ko/hook-design-principles.md b/docs/ko/hook-design-principles.md new file mode 100644 index 00000000..968a102c --- /dev/null +++ b/docs/ko/hook-design-principles.md @@ -0,0 +1,329 @@ +# React Hook Design Principles + +> ์ตœ์ข… ์—…๋ฐ์ดํŠธ: 2026-04-03 +> ์ƒํƒœ: Draft (๋…ผ์˜ ํ›„ ํ™•์ •) + +--- + +## 1. ์š”๊ตฌ์‚ฌํ•ญ + +### ๋ฐฐ๊ฒฝ + +react-simplikit์„ ์šด์˜ํ•˜๋ฉฐ ์ถ•์ ํ•œ ํ›… ์„ค๊ณ„ ์ฒ ํ•™์„ **ํ•˜๋‚˜์˜ ๊ณตํ†ต ์›์น™**์œผ๋กœ ์ •์˜ํ•œ๋‹ค. ์ด ์›์น™์€ ๋‘ ๊ฐ€์ง€ ์šฉ๋„๋กœ ์‚ฌ์šฉ๋œ๋‹ค: + +1. **์ฝ”๋“œ ๋ฆฌ๋ทฐ** โ€” `react-hook-review` ์Šคํ‚ฌ์ด ์ด ์›์น™ ๊ธฐ๋ฐ˜์œผ๋กœ ํ”ผ๋“œ๋ฐฑ +2. **์ฝ”๋“œ ์ž‘์„ฑ** โ€” `react-hook-writing` ์Šคํ‚ฌ์ด ์ด ์›์น™ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ฐ€์ด๋“œ + +### ์›์น™์˜ ๋‘ ๊ฐ€์ง€ ๋ฐฉํ–ฅ + +| ๋ฐฉํ–ฅ | ์ถœ์ฒ˜ | ๋ฒ”์œ„ | +|------|------|------| +| **ํ›… ์ฝ”๋”ฉ ์›์น™** (Section 2) | CLAUDE.md, AGENTS.md, ๋‚ด๋ถ€ ์Šคํ‚ฌ | ๋ฐ˜ํ™˜๊ฐ’, TypeScript, ์„ฑ๋Šฅ, ๋ฌธ์„œํ™” ๋“ฑ ์ฝ”๋”ฉ ์Šคํƒ€์ผ | +| **ํ›… ์‚ฌ์šฉ ํŒจํ„ด** (Section 3) | React ๊ณต์‹ ๋ฌธ์„œ (react.dev) | state ์„ค๊ณ„, effect ์‚ฌ์šฉ๋ฒ•, ๋ฉ”๋ชจ์ด์ œ์ด์…˜, ์ปค์Šคํ…€ ํ›… ์„ค๊ณ„ | + +### ํ•ต์‹ฌ ์š”๊ตฌ์‚ฌํ•ญ + +| # | ์š”๊ตฌ์‚ฌํ•ญ | ์ƒ์„ธ | +|---|---------|------| +| R1 | ๋ฆฌ๋ทฐ/์ƒ์„ฑ ๊ณตํ†ต ์›์น™ | ๋‘ ์Šคํ‚ฌ์ด ๋™์ผํ•œ ์›์น™ ์ฐธ์กฐ | +| R2 | Why ์ค‘์‹ฌ | ๊ทœ์น™(What)๋งŒ ๋‚˜์—ดํ•˜์ง€ ์•Š๊ณ  ์ฒ ํ•™(Why)์„ narrative๋กœ ์„ค๋ช… | +| R3 | Opinionated ํˆฌ๋ช…์„ฑ | ๐ŸŸข Best Practice vs ๐ŸŸก Opinionated ๋ช…์‹œ | +| R4 | ํ”„๋กœ์ ํŠธ ๋ฌด๊ด€ | react-simplikit ๊ฒฝ๋กœ/๋ช…๋ น์–ด/์œ ํ‹ธ ์—†์ด ๋ฒ”์šฉ ์›์น™๋งŒ | +| R5 | Cross-tool | Claude Code ํ”Œ๋Ÿฌ๊ทธ์ธ + Codex(AGENTS.md) + Cursor(.cursorrules) | + +### ๊ฒฐ์ • ํ•„์š” ์‚ฌํ•ญ + +| # | ์งˆ๋ฌธ | ์„ ํƒ์ง€ | +|---|------|--------| +| Q1 | C14(Named useEffect)๋ฅผ ํฌํ•จํ• ์ง€? | A) "Recommended"๋กœ ํฌํ•จ B) ์ œ์™ธ | +| Q2 | C2(SSR-Safe)๋ฅผ ๋น„-SSR ํ”„๋กœ์ ํŠธ์—๋„ ๊ถŒ์žฅํ• ์ง€? | A) ํ•ญ์ƒ B) SSR ์‚ฌ์šฉ ์‹œ๋งŒ | +| Q3 | C9(JSDoc)์˜ @example์„ ํ•„์ˆ˜๋กœ ํ• ์ง€? | A) 4-tag ์ „๋ถ€ ํ•„์ˆ˜ B) @example์€ ๊ถŒ์žฅ | +| Q4 | ์ถ”๊ฐ€ํ•  ์›์น™์ด ์žˆ๋Š”์ง€? | โ€” | +| Q5 | ์›์น™ ๋จผ์ € ํ™•์ •ํ• ์ง€, ๋ฐ”๋กœ ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ตฌ์กฐ๋กœ ๊ฐˆ์ง€? | A) ์›์น™ ๋จผ์ € B) ๋ฐ”๋กœ ํ”Œ๋Ÿฌ๊ทธ์ธ | +| Q6 | ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐฐํฌ ์ฑ„๋„ | A) git-subdir B) npm C) ๋ฏธ์ • | + +--- + +## 2. ํ›… ์ฝ”๋”ฉ ์›์น™ (Direction 1) + +CLAUDE.md + AGENTS.md + ๋‚ด๋ถ€ ์Šคํ‚ฌ์—์„œ ์ถ”์ถœํ•œ **์ฝ”๋”ฉ ์Šคํƒ€์ผ** ์›์น™. + +### ๐ŸŸข Best Practice (13๊ฐœ) + +#### C1. ํ•ญ์ƒ ๊ฐ์ฒด ๋ฐ˜ํ™˜ + +๋ฐ˜ํ™˜๊ฐ’์ด 1๊ฐœ์—ฌ๋„ `{ value }` ํ˜•ํƒœ. ๊ฐ์ฒด๋Š” ์ˆœ์„œ ๋ฌด๊ด€, ์ด๋ฆ„์œผ๋กœ ์˜๋ฏธ ์ „๋‹ฌ, ํ™•์žฅ ์‹œ breaking change ์—†์Œ. + +```ts +function useDebounce(value: T, delay: number): { value: T } +function useToggle(init: boolean): { value: boolean; toggle: () => void } +function usePagination(): { page: number; next: () => void; prev: () => void } +``` + +#### C2. SSR-Safe ์ดˆ๊ธฐํ™” + +`useState(FIXED_VALUE)` + `useEffect(sync)`. ๋ธŒ๋ผ์šฐ์ € API ์ดˆ๊ธฐํ™” ๊ธˆ์ง€. ์„œ๋ฒ„์— `window` ์—†์Œ โ†’ ํฌ๋ž˜์‹œ ๋˜๋Š” hydration mismatch. + +```ts +// โœ… SSR ์•ˆ์ „ +const [width, setWidth] = useState(0); +useEffect(function syncWidth() { setWidth(window.innerWidth); }, []); + +// โŒ SSR ํฌ๋ž˜์‹œ +const [width, setWidth] = useState(window.innerWidth); + +// โš ๏ธ ํด๋ผ์ด์–ธํŠธ ์ „์šฉ ์•ฑ์—์„œ๋งŒ ํ—ˆ์šฉ +const [width, setWidth] = useState(() => { + if (typeof window === 'undefined') return 0; + return window.innerWidth; +}); +``` + +#### C3. useEffect Cleanup ํ•„์ˆ˜ + +๋ชจ๋“  ๋ถ€์ˆ˜ํšจ๊ณผ์— cleanup ๋ฐ˜ํ™˜. ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€. StrictMode ์ด์ค‘ ๋งˆ์šดํŠธ๊ฐ€ ์ฆ‰์‹œ ๋…ธ์ถœ. + +```ts +// ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ +useEffect(function subscribe() { + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); +}, []); + +// AbortController (๋น„๋™๊ธฐ) +useEffect(function fetchData() { + const controller = new AbortController(); + fetch(url, { signal: controller.signal }).then(/* ... */); + return () => controller.abort(); +}, [url]); + +// ํƒ€์ด๋จธ +useEffect(function tick() { + const id = setInterval(callback, 1000); + return () => clearInterval(id); +}, []); +``` + +#### C4. No `any` Types + +์ œ๋„ค๋ฆญ `` ์‚ฌ์šฉ. any ์ „ํŒŒ โ†’ ํƒ€์ž… ์‹œ์Šคํ…œ ๋ฌด๋ ฅํ™”. ์ •๋‹นํ•œ ์‚ฌ์œ (generic callback ๋“ฑ) ์‹œ per-line eslint-disable + ์ฝ”๋ฉ˜ํŠธ ํ—ˆ์šฉ. + +```ts +// โœ… Generic +function useDebounce(value: T, delay: number): T + +// โœ… ์ •๋‹นํ•œ ์˜ˆ์™ธ (์ฝ”๋ฉ˜ํŠธ ํ•„์ˆ˜) +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic callback +type AnyFunction = (...args: any[]) => unknown; +``` + +#### C5. Named Exports Only + +tree-shaking ๋ณด์žฅ + import ๋ช…ํ™•์„ฑ. `export default` ๊ธˆ์ง€. + +#### C6. Strict Boolean & Nullish Checks + +`if (value)` ๊ธˆ์ง€ โ†’ 0, "", false falsy ๋ฒ„๊ทธ ๋ฐฉ์ง€. `== null`๋กœ null+undefined ๋™์‹œ ์ฒดํฌ. + +```ts +if (ref == null) { return; } // โœ… null + undefined +const controlled = valueProp !== undefined; // โœ… ๊ตฌ๋ถ„ ํ•„์š”ํ•  ๋•Œ +if (count) { ... } // โŒ count=0 ํ†ต๊ณผ ๋ชปํ•จ +``` + +#### C7. Parameter๋Š” ๊ฐ์ฒด๋กœ ๋ฐ›๊ธฐ + +ํ›…์˜ ์ธ์ž๋ฅผ ๊ฐœ๋ณ„ ํŒŒ๋ผ๋ฏธํ„ฐ ๋Œ€์‹  ๊ฐ์ฒด(props)๋กœ. ์ˆœ์„œ ๋ฌด๊ด€ + ์ด๋ฆ„์œผ๋กœ ์˜๋ฏธ ์ „๋‹ฌ + ํ™•์žฅ ์‹œ breaking change ์—†์Œ. + +```ts +// โœ… ๊ฐ์ฒด +function useDebounce({ value, delay, leading }: { + value: T; delay: number; leading?: boolean; +}): { value: T } + +// โŒ ์œ„์น˜ ๊ธฐ๋ฐ˜ +function useDebounce(value: T, delay: number, leading?: boolean): { value: T } +``` + +#### C8. Guard Clauses (Early Return) + +nested if-else ๋Œ€์‹  early return. ์‹คํŒจ ์กฐ๊ฑด ๋จผ์ € ๊ฑธ๋Ÿฌ๋‚ด๊ณ  ์„ฑ๊ณต ๋กœ์ง์€ ํ”Œ๋žซํ•˜๊ฒŒ. + +```ts +// โœ… +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; } +} +``` + +#### C9. JSDoc 4-Tag + +๋ชจ๋“  public API์— `@description` + `@param` + `@returns` + `@example`. AI ๋ฌธ์„œ ์ƒ์„ฑ + IDE ํˆดํŒ. + +```ts +/** + * @description ๊ฐ’์˜ ๋ณ€๊ฒฝ์„ ์ง€์—ฐ์‹œํ‚จ๋‹ค. + * @param value - ๋””๋ฐ”์šด์Šคํ•  ๊ฐ’ + * @param delay - ์ง€์—ฐ ์‹œ๊ฐ„ (ms) + * @returns ๋””๋ฐ”์šด์Šค๋œ ๊ฐ’ + * @example + * const debouncedQuery = useDebounce(query, 300); + */ +``` + +#### C10. Performance Patterns + +๊ณ ๋นˆ๋„(30+/sec) ์ด๋ฒคํŠธ์—๋งŒ ์ ์šฉ. ์ผ๋ฐ˜ ํ›…์—๋Š” ๋ถˆํ•„์š”. + +| ๊ธฐ๋ฒ• | ์ ์šฉ ์‹œ์  | +|------|-----------| +| Throttle (16ms) | scroll, resize, pointer, keyboard | +| Deduplicate | ๊ฐ’ ๋ฏธ๋ณ€๊ฒฝ ์‹œ setState skip | +| startTransition | ๋น„๊ธด๊ธ‰ ํŒŒ์ƒ ๊ณ„์‚ฐ (React 18+) | + +#### C11. Function Keyword for Declarations + +ํ•จ์ˆ˜ ์„ ์–ธ์€ `function` ํ‚ค์›Œ๋“œ. ํ™”์‚ดํ‘œ๋Š” ์ธ๋ผ์ธ ์ฝœ๋ฐฑ(map, filter)์—๋งŒ. + +```ts +function toggle(state: boolean) { return !state; } // โœ… ์„ ์–ธ +items.filter(item => item != null); // โœ… ์ธ๋ผ์ธ +const toggle = (state: boolean) => !state; // โŒ ์„ ์–ธ์— ํ™”์‚ดํ‘œ +``` + +#### C12. Zero Runtime Dependencies + +ํ”„๋กœ๋•์…˜ ์ฝ”๋“œ์— ์™ธ๋ถ€ ๋Ÿฐํƒ€์ž„ ์˜์กด์„ฑ ๊ธˆ์ง€. `peerDependencies`๋งŒ ํ—ˆ์šฉ. ๋ฒˆ๋“ค ์‚ฌ์ด์ฆˆ ์ตœ์†Œํ™” + ์˜์กด์„ฑ ์ถฉ๋Œ ๋ฐฉ์ง€. + +#### C13. ์™ธ๋ถ€ ์˜์กด์„ฑ ์ง์ ‘ ์ฐธ์กฐ ์ง€์–‘ + +ํ›… ๋‚ด๋ถ€์—์„œ ์™ธ๋ถ€ ๋ชจ๋“ˆ์„ ์ง์ ‘ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ์ธ์ž๋กœ ์ฃผ์ž…. ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ + ๊ต์ฒด ๊ฐ€๋Šฅ์„ฑ. + +```ts +// โœ… ์˜์กด์„ฑ ์ฃผ์ž… +function useFetch(fetcher: (url: string) => Promise, url: string) { ... } + +// โŒ ์™ธ๋ถ€ ๋ชจ๋“ˆ ์ง์ ‘ +function useFetch(url: string) { const res = await axios.get(url); ... } +``` + +### ๐ŸŸก Opinionated (1๊ฐœ) + +#### C14. Named useEffect Functions + +`useEffect(function handleResize() {...})`. ์—๋Ÿฌ ์Šคํƒ์—์„œ "handleResize" vs "anonymous". Trade-off: ํ™”์‚ดํ‘œ๋ณด๋‹ค ์žฅํ™ฉ. cleanup ์ด๋ฆ„์€ "Recommended" (ํ•„์ˆ˜ ์•„๋‹˜). + +### ์ œ์™ธ (ํ”„๋กœ์ ํŠธ๋ณ„ ๊ฒฐ์ •) + +| ํ•ญ๋ชฉ | ์ด์œ  | +|------|------| +| Import extensions (.js/.ts) | ๋นŒ๋“œ ๋„๊ตฌ ์˜์กด์  | +| 100% test coverage | ํ”„๋กœ์ ํŠธ ์ •์ฑ… | +| ํŒŒ์ผ ๊ตฌ์กฐ/์ปค๋ฐ‹ ์ปจ๋ฒค์…˜ | ํ›… ์„ค๊ณ„ ์ฒ ํ•™ ์•„๋‹˜ | + +--- + +## 3. ํ›… ์‚ฌ์šฉ ํŒจํ„ด (Direction 2) + +> ๋ณ„๋„ ๋ฌธ์„œ: [react-hook-usage-patterns.md](./react-hook-usage-patterns.md) + +React ๊ณต์‹ ๋ฌธ์„œ(react.dev) ๊ธฐ๋ฐ˜ 17๊ฐœ ํŒจํ„ด (U1-U17): + +| ์นดํ…Œ๊ณ ๋ฆฌ | ๊ฐœ์ˆ˜ | ํ•ต์‹ฌ | +|----------|------|------| +| State Design | U1-U7 | ํŒŒ์ƒ๊ฐ’ ๊ณ„์‚ฐ, props ๋ณต์‚ฌ ๊ธˆ์ง€, useRef, union type, state ๊ทธ๋ฃนํ™” | +| Effect Usage | U8-U14 | effect๋Š” ์™ธ๋ถ€ ๋™๊ธฐํ™” ์ „์šฉ, ์ฒด์ธ ๊ธˆ์ง€, key ๋ฆฌ์…‹, ๋น„๋™๊ธฐ cleanup | +| Memoization | U15-U16 | useMemo 1ms+, useCallback + memo() ์กฐํ•ฉ๋งŒ | +| Hook Design | U17 | lifecycle wrapper ๊ธˆ์ง€, ๊ตฌ์ฒด์  ๋ชฉ์  ํ›…๋งŒ | + +--- + +## 4. ํ”Œ๋Ÿฌ๊ทธ์ธ ์•„ํ‚คํ…์ฒ˜ + +### ํŒŒ์ƒ ํ๋ฆ„ + +``` +์ด ๋ฌธ์„œ (principles, ์›์น™ ์ •์˜) + โ†“ ์••์ถ• +react-hook-review/SKILL.md (์ฒดํฌ๋ฆฌ์ŠคํŠธ) +react-hook-writing/SKILL.md (๊ฐ€์ด๋“œ) + โ†“ ์ถ”๊ฐ€ ์••์ถ• +AGENTS.md Part 1 (Codex์šฉ) + โ†“ ์ฐธ์กฐ +.cursorrules (Cursor์šฉ) +``` + +### ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ + +``` +packages/plugin/ (planned) +โ”œโ”€โ”€ .claude-plugin/plugin.json +โ”œโ”€โ”€ .codex-plugin/plugin.json +โ”œโ”€โ”€ principles/ โ† ๊ณตํ†ต ์›์น™ Single Source +โ”œโ”€โ”€ skills/ +โ”‚ โ”œโ”€โ”€ react-hook-review/SKILL.md โ† C1-C14 + U1-U17 ์ฒดํฌ๋ฆฌ์ŠคํŠธ +โ”‚ โ””โ”€โ”€ react-hook-writing/ +โ”‚ โ”œโ”€โ”€ SKILL.md โ† ํ…Œ๋งˆ๋ณ„ ๊ฐ€์ด๋“œ +โ”‚ โ””โ”€โ”€ references/patterns.md โ† ๊ตฌํ˜„ ์˜ˆ์‹œ 3๊ฐœ +โ””โ”€โ”€ README.md +``` + +### Cross-Tool ์ง€์› + +| ๋„๊ตฌ | ํŒŒ์ผ | ํ˜„์žฌ | ๋ณ€๊ฒฝ | +|------|------|------|------| +| Claude Code (๋‚ด๋ถ€) | `.claude/skills/` | โœ… 10๊ฐœ | ์œ ์ง€ | +| Claude Code (ํ”Œ๋Ÿฌ๊ทธ์ธ) | `packages/plugin/` | โŒ | Phase 1-5๋กœ ์ƒ์„ฑ | +| Codex | `AGENTS.md` | โœ… 162์ค„ | Part 1(Universal) + Part 2(Project) ๋ถ„๋ฆฌ | +| Cursor | `.cursorrules` | โœ… 28์ค„ | AGENTS.md ์ฐธ์กฐ ์œ ์ง€ | + +### ์ถ”์ถœ ๊ทœ์น™ + +| ์ถ”์ถœ๋จ (์ฒ ํ•™) | ๋‚จ๊ฒจ์ง (๊ตฌํ˜„) | +|--------------|-------------| +| "ํ•ญ์ƒ ๊ฐ์ฒด ๋ฐ˜ํ™˜" | `packages/core/src/hooks/` ๊ฒฝ๋กœ | +| "Named useEffect improves stack traces" | `yarn test`, `yarn fix` ๋ช…๋ น | +| "SSR-safe: fixed initial + useEffect sync" | `renderHookSSR.serverOnly()` ์œ ํ‹ธ | +| "4 JSDoc tags for AI doc generation" | `100%` coverage ๊ธฐ์ค€ | + +### ์ผ๋ฐ˜ํ™” ๋ณ€ํ™˜ + +| Before (ํ”„๋กœ์ ํŠธ ์ „์šฉ) | After (๋ฒ”์šฉ) | +|---|---| +| `renderHookSSR.serverOnly()` | Vitest + `delete global.window` | +| `yarn test` / `yarn fix` | "Run your test suite" | +| `packages/core/` ๊ฒฝ๋กœ | "your source directory" | +| `react-simplikit` ์–ธ๊ธ‰ | ์ œ๊ฑฐ | + +--- + +## 5. ์‹คํ–‰ ๋กœ๋“œ๋งต + +| Phase | ๋‚ด์šฉ | ์‚ฐ์ถœ๋ฌผ | +|-------|------|--------| +| 1 | ๋””๋ ‰ํ† ๋ฆฌ + plugin.json + README | `packages/plugin/` ๊ตฌ์กฐ | +| 2 | react-hook-review SKILL.md | C1-C14 + U1-U17 ์ฒดํฌ๋ฆฌ์ŠคํŠธ | +| 3 | react-hook-writing SKILL.md + patterns.md | ํ…Œ๋งˆ๋ณ„ ๊ฐ€์ด๋“œ + 3๊ฐœ ํ›… ์˜ˆ์‹œ | +| 4 | ์ผ๋ฐ˜ํ™” ๊ฒ€์ฆ (grep) | ํ”„๋กœ์ ํŠธ ์ฐธ์กฐ 0๊ฑด | +| 5 | ํ”Œ๋Ÿฌ๊ทธ์ธ validate + ๋กœ์ปฌ ํ…Œ์ŠคํŠธ | ๋™์ž‘ ํ™•์ธ | + +### ๊ฒ€์ฆ ๊ธฐ์ค€ + +| ํ•ญ๋ชฉ | ํ†ต๊ณผ ๊ธฐ์ค€ | +|------|---------| +| ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ตฌ์กฐ | `claude plugin validate .` ์—๋Ÿฌ 0 | +| ๋ฒ”์šฉ์„ฑ | ๋‹ค๋ฅธ React ํ”„๋กœ์ ํŠธ์—์„œ ํ”„๋กœ์ ํŠธ ์ฐธ์กฐ 0๊ฑด | +| ์ฒ ํ•™ ๊นŠ์ด | ๊ฐ ๊ทœ์น™์˜ Why๊ฐ€ narrative | +| Opinionated ํˆฌ๋ช…์„ฑ | ๐ŸŸก ํŒจํ„ด์— trade-off ์กด์žฌ | + +### ํ–ฅํ›„ ํ™•์žฅ + +- Codex/Gemini ๋Œ€์‘ (AGENTS.md Part 1 ํ™œ์šฉ) +- Component ์„ค๊ณ„ ์ฒ ํ•™ ์ถ”๊ฐ€ +- Marketplace ์ „ํ™˜ (Plugin 3๊ฐœ+ ์‹œ) diff --git a/docs/ko/react-hook-usage-patterns.md b/docs/ko/react-hook-usage-patterns.md new file mode 100644 index 00000000..e7157bfd --- /dev/null +++ b/docs/ko/react-hook-usage-patterns.md @@ -0,0 +1,155 @@ +# React Hook Usage Patterns + +> ์ตœ์ข… ์—…๋ฐ์ดํŠธ: 2026-04-03 +> ์ถœ์ฒ˜: React ๊ณต์‹ ๋ฌธ์„œ (react.dev) +> ๊ด€๋ จ: [Hook Design Principles](./hook-design-principles.md) + +์ฝ”๋”ฉ ์Šคํƒ€์ผ์ด ์•„๋‹Œ **hooks๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์‚ฌ์šฉํ•˜๋Š” ํŒจํ„ด**. 17๊ฐœ ์›์น™. + +--- + +## State Design (6๊ฐœ) + +### U1. ํŒŒ์ƒ ๊ฐ€๋Šฅํ•œ ๊ฐ’์€ state์— ๋„ฃ์ง€ ๋งˆ๋ผ + +๊ธฐ์กด props/state์—์„œ ๊ณ„์‚ฐ ๊ฐ€๋Šฅํ•œ ๊ฐ’์€ ๋ Œ๋” ์ค‘์— ๊ณ„์‚ฐ. useEffect ๋™๊ธฐํ™” โ†’ 1๋ Œ๋” ์ง€์—ฐ + ๋ถˆํ•„์š”ํ•œ ์ถ”๊ฐ€ ๋ Œ๋”. + +```ts +// โŒ const [fullName, setFullName] = useState(''); +// useEffect(() => { setFullName(first + ' ' + last); }, [first, last]); +// โœ… const fullName = first + ' ' + last; +``` + +### U2. props๋ฅผ state์— ๋ณต์‚ฌํ•˜์ง€ ๋งˆ๋ผ + +prop์„ useState์— ๋„ฃ์œผ๋ฉด ๋ถ€๋ชจ ๋ณ€๊ฒฝ ๋ฌด์‹œ๋จ. ์ง์ ‘ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ `initialX`๋กœ ๋ช…๋ช…. + +```ts +// โŒ const [color, setColor] = useState(messageColor); +// โœ… const color = messageColor; +// โœ… function Message({ initialColor }: ...) { const [color, setColor] = useState(initialColor); } +``` + +### U3. ๋ Œ๋”์— ์˜ํ–ฅ ์—†๋Š” ๊ฐ’์€ useRef + +interval ID, ์ด์ „๊ฐ’, ๋‚ด๋ถ€ ํ”Œ๋ž˜๊ทธ โ†’ useState ๋Œ€์‹  useRef. `ref.current`๋Š” ๋ Œ๋” ์ค‘ ์ฝ๊ธฐ/์“ฐ๊ธฐ ๊ธˆ์ง€. + +```ts +// โŒ const [intervalId, setIntervalId] = useState(null); +// โœ… const intervalRef = useRef(null); +``` + +### U5. ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋ฅผ discriminated union์œผ๋กœ ์ œ๊ฑฐ + +N๊ฐœ boolean โ†’ 2^N ์กฐํ•ฉ. ๋‹จ์ผ status union์œผ๋กœ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ ํƒ€์ž… ๋ ˆ๋ฒจ ์ฐจ๋‹จ. + +```ts +// โŒ const [isSending, setIsSending] = useState(false); +// const [isSent, setIsSent] = useState(false); +// โœ… type Status = 'typing' | 'sending' | 'sent'; +// const [status, setStatus] = useState('typing'); +``` + +### U6. ๊ฐ์ฒด ๋ณต์‚ฌ ๋Œ€์‹  ID ์ €์žฅ + +๋ฆฌ์ŠคํŠธ์—์„œ ์„ ํƒ๋œ ํ•ญ๋ชฉ์„ state์— ๋ณต์‚ฌ โ†’ ์›๋ณธ ์ˆ˜์ • ์‹œ stale. ID๋งŒ ์ €์žฅ + ๋ Œ๋” ์‹œ ํŒŒ์ƒ. + +```ts +// โŒ const [selectedItem, setSelectedItem] = useState(items[0]); +// โœ… const [selectedId, setSelectedId] = useState(items[0].id); +// const selectedItem = items.find(i => i.id === selectedId); +``` + +### U7. ๊ด€๋ จ state๋Š” ํ•˜๋‚˜์˜ ๊ฐ์ฒด๋กœ ๊ทธ๋ฃนํ™” + +ํ•ญ์ƒ ํ•จ๊ป˜ ๋ณ€ํ•˜๋Š” state โ†’ ํ•˜๋‚˜์˜ setState๋กœ ์›์ž์  ์—…๋ฐ์ดํŠธ. + +```ts +// โŒ const [x, setX] = useState(0); const [y, setY] = useState(0); +// โœ… const [position, setPosition] = useState({ x: 0, y: 0 }); +``` + +--- + +## Effect Usage (7๊ฐœ) + +### U8. useEffect๋Š” ์™ธ๋ถ€ ์‹œ์Šคํ…œ ๋™๊ธฐํ™” ์ „์šฉ + +๋„คํŠธ์›Œํฌ, DOM API, ๋ธŒ๋ผ์šฐ์ € API ๋™๊ธฐํ™”์—๋งŒ ์‚ฌ์šฉ. ์ด๋ฒคํŠธ ํ•ธ๋“ค๋ง, ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜์—๋Š” ์“ฐ์ง€ ๋งˆ๋ผ. + +```ts +// โŒ useEffect(() => { if (product.isInCart) showNotification('Added!'); }, [product]); +// โœ… function handleBuy() { addToCart(product); showNotification('Added!'); } +``` + +### U9. useEffect ์ฒด์ธ ๊ธˆ์ง€ + +ํ•˜๋‚˜์˜ effect๊ฐ€ setState โ†’ ๋‹ค์Œ effect ํŠธ๋ฆฌ๊ฑฐ โ†’ ์ˆœ์ฐจ ๋ฆฌ๋ Œ๋” + ์ถ”์  ๋ถˆ๊ฐ€. ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๋‚˜ reducer๋กœ ํ†ตํ•ฉ. + +### U10. state ๋ฆฌ์…‹์€ key prop์œผ๋กœ + +`key={id}`๋กœ ์žฌ๋งˆ์šดํŠธ. useEffect ๋ฆฌ์…‹ โ†’ stale ๊ฐ’ ํ•œ ํ”„๋ ˆ์ž„ ๋…ธ์ถœ. + +```ts +// โŒ useEffect(() => { setComment(''); }, [userId]); +// โœ… +``` + +### U11. effect ์•ˆ์—์„œ๋งŒ ์“ฐ๋Š” ๊ฐ์ฒด/ํ•จ์ˆ˜๋Š” effect ๋‚ด๋ถ€์— ์„ ์–ธ + +์ปดํฌ๋„ŒํŠธ ๋ณธ๋ฌธ์— ์„ ์–ธ โ†’ ๋งค ๋ Œ๋” ์ƒˆ ์ฐธ์กฐ โ†’ effect ๋งค๋ฒˆ ์žฌ์‹คํ–‰. + +```ts +// โŒ const options = { serverUrl, roomId }; +// useEffect(() => { connect(options); }, [options]); +// โœ… useEffect(() => { +// const options = { serverUrl, roomId }; +// connect(options); +// }, [roomId]); +``` + +### U12. ์™ธ๋ถ€ ์Šคํ† ์–ด ๊ตฌ๋…์€ useSyncExternalStore + +๋ธŒ๋ผ์šฐ์ € API, ์„œ๋“œํŒŒํ‹ฐ ์Šคํ† ์–ด ๊ตฌ๋… โ†’ useState+useEffect ๋Œ€์‹  useSyncExternalStore. concurrent rendering tearing ๋ฐฉ์ง€ + SSR ์„œ๋ฒ„ ์Šค๋ƒ…์ƒท ์ง€์›. + +### U13. ๋ถ€๋ชจ ์•Œ๋ฆผ์€ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์—์„œ + +์ž์‹์ด ๋ถ€๋ชจ์—๊ฒŒ state ๋ณ€๊ฒฝ ์•Œ๋ฆด ๋•Œ useEffect๊ฐ€ ์•„๋‹Œ ๊ฐ™์€ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์ฝœ๋ฐฑ ํ˜ธ์ถœ. ์—ฐ์‡„ ๋ฆฌ๋ Œ๋” ๋ฐฉ์ง€. + +```ts +// โŒ useEffect(() => { onChange(isOn); }, [isOn]); +// โœ… function handleClick() { setIsOn(!isOn); onChange(!isOn); } +``` + +### U14. ๋น„๋™๊ธฐ effect๋Š” ๋ฐ˜๋“œ์‹œ cleanup + +fetch/timer/subscription โ†’ cleanup ์—†์œผ๋ฉด race condition. ๋น ๋ฅธ prop ๋ณ€๊ฒฝ ์‹œ ์ด์ „ ์‘๋‹ต์ด ์ดํ›„ ์‘๋‹ต์„ ๋ฎ์–ด์”€. + +```ts +useEffect(function fetchResults() { + let ignore = false; + fetchAPI(query).then(data => { if (!ignore) setResults(data); }); + return () => { ignore = true; }; +}, [query]); +``` + +--- + +## Memoization (2๊ฐœ) + +### U15. useMemo๋Š” 1ms ์ด์ƒ ์ธก์ •๋œ ์—ฐ์‚ฐ์—๋งŒ + +`console.time`์œผ๋กœ ์ธก์ •ํ•ด์„œ 1ms ๋ฏธ๋งŒ์ด๋ฉด useMemo ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ๋” ํผ. + +### U16. useCallback์€ memo() ๋ž˜ํ•‘๋œ ์ž์‹์— ์ „๋‹ฌํ•  ๋•Œ๋งŒ + +memo() ์—†๋Š” ์ž์‹์— stable reference โ†’ ๋ฆฌ๋ Œ๋” ๋ฐฉ์ง€ ํšจ๊ณผ ์—†์Œ. + +--- + +## Hook Design (1๊ฐœ) + +### U17. ์ปค์Šคํ…€ ํ›…์€ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ ๋กœ์ง ์ถ”์ถœ์šฉ + +lifecycle wrapper(`useMount`, `useEffectOnce`) ๊ธˆ์ง€. ๊ตฌ์ฒด์  ๋™๊ธฐํ™” ๋ชฉ์  ํ›…(`useWindowSize`, `useOnlineStatus`)๋งŒ. +์ถ”์ถœ ๊ธฐ์ค€: ๋™์ผ state+effect ํŒจํ„ด์ด 2๊ฐœ+ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฐ˜๋ณต๋˜๋Š”์ง€? diff --git a/docs/react-hook-usage-patterns.md b/docs/react-hook-usage-patterns.md new file mode 100644 index 00000000..b45d7aab --- /dev/null +++ b/docs/react-hook-usage-patterns.md @@ -0,0 +1,204 @@ +# React Hook Usage Patterns + +> Last Updated: 2026-04-07 +> Source: React official documentation (react.dev) +> Related: [Hook Design Principles](./hook-design-principles.md) +> Korean version: [ko/react-hook-usage-patterns.md](./ko/react-hook-usage-patterns.md) + +Patterns for **correctly using hooks** โ€” not coding style, but React-specific best practices. 17 principles. + +--- + +## State Design (6) + +### U1. Derive Instead of Syncing with State + +If a value can be computed from existing props or state, calculate it during render. Syncing with useEffect causes a 1-render delay + unnecessary extra render. + +> ๐Ÿ“– [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +> *"If something can be calculated from the existing props or state, don't put it in state. Instead, calculate it during rendering."* + +```ts +// โŒ const [fullName, setFullName] = useState(''); +// useEffect(() => { setFullName(first + ' ' + last); }, [first, last]); +// โœ… const fullName = first + ' ' + last; +``` + +### U2. Don't Mirror Props in State + +Copying a prop into useState means parent changes are silently ignored. Use the prop directly, or name it `initialX` if intentional. + +> ๐Ÿ“– [Choosing the State Structure โ€” Avoid redundant state](https://react.dev/learn/choosing-the-state-structure#avoid-redundant-state) +> *"If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state."* + +```ts +// โŒ const [color, setColor] = useState(messageColor); +// โœ… const color = messageColor; +// โœ… function Message({ initialColor }: ...) { const [color, setColor] = useState(initialColor); } +``` + +### U3. Use useRef for Non-Rendered Values + +Interval IDs, previous values, internal flags โ€” use useRef instead of useState. Avoids unnecessary re-renders. Never read/write `ref.current` during rendering. + +> ๐Ÿ“– [Referencing Values with Refs](https://react.dev/learn/referencing-values-with-refs) +> *"When you want a component to 'remember' some information, but you don't want that information to trigger new renders, you can use a ref."* + +```ts +// โŒ const [intervalId, setIntervalId] = useState(null); +// โœ… const intervalRef = useRef(null); +``` + +### U5. Eliminate Impossible States with Discriminated Unions + +N booleans โ†’ 2^N combinations with invalid states. A single status union type prevents impossible states at the type level. + +> ๐Ÿ“– [Choosing the State Structure โ€” Avoid contradictions in state](https://react.dev/learn/choosing-the-state-structure#avoid-contradictions-in-state) +> *"Since isSending and isSent should never be true at the same time, it is better to replace them with one status state variable."* + +```ts +// โŒ const [isSending, setIsSending] = useState(false); +// const [isSent, setIsSent] = useState(false); +// โœ… type Status = 'typing' | 'sending' | 'sent'; +// const [status, setStatus] = useState('typing'); +``` + +### U6. Store IDs Instead of Duplicating Objects + +Copying a selected item from a list into state โ†’ stale when source updates. Store the ID and derive during render. + +> ๐Ÿ“– [Choosing the State Structure โ€” Avoid duplication in state](https://react.dev/learn/choosing-the-state-structure#avoid-duplication-in-state) +> *"The contents of the selectedItem is the same object as one of the items inside the items list. This means that the information about the item itself is duplicated in two places."* + +```ts +// โŒ const [selectedItem, setSelectedItem] = useState(items[0]); +// โœ… const [selectedId, setSelectedId] = useState(items[0].id); +// const selectedItem = items.find(i => i.id === selectedId); +``` + +### U7. Group Related State into a Single Object + +State values that always change together โ†’ single setState for atomic updates. + +> ๐Ÿ“– [Choosing the State Structure โ€” Group related state](https://react.dev/learn/choosing-the-state-structure#group-related-state) +> *"If some two state variables always change together, it might be a good idea to unify them into a single state variable."* + +```ts +// โŒ const [x, setX] = useState(0); const [y, setY] = useState(0); +// โœ… const [position, setPosition] = useState({ x: 0, y: 0 }); +``` + +--- + +## Effect Usage (7) + +### U8. useEffect Is for External System Synchronization Only + +Network, DOM APIs, browser APIs โ€” synchronization only. Not for event handling or data transformation. + +> ๐Ÿ“– [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +> *"Effects are an escape hatch from the React paradigm. They let you 'step outside' of React and synchronize your components with some external system."* + +```ts +// โŒ useEffect(() => { if (product.isInCart) showNotification('Added!'); }, [product]); +// โœ… function handleBuy() { addToCart(product); showNotification('Added!'); } +``` + +### U9. No useEffect Chains + +One effect sets state โ†’ triggers next effect โ†’ cascading re-renders + untraceable. Consolidate in event handlers or reducers. + +> ๐Ÿ“– [You Might Not Need an Effect โ€” Chains of computations](https://react.dev/learn/you-might-not-need-an-effect#chains-of-computations) +> *"The component (and its children) have to re-render between each set call in the chain."* + +### U10. Reset State with key Prop + +`key={id}` forces a clean remount. useEffect reset โ†’ stale value visible for one frame. + +> ๐Ÿ“– [You Might Not Need an Effect โ€” Resetting all state when a prop changes](https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes) +> *"Instead, you can tell React that each user's profile is conceptually a different profile by giving it an explicit key."* + +```ts +// โŒ useEffect(() => { setComment(''); }, [userId]); +// โœ… +``` + +### U11. Declare Effect-Only Objects/Functions Inside the Effect + +Objects/functions declared in the component body get new references every render โ†’ effect re-runs every render. + +> ๐Ÿ“– [Removing Effect Dependencies โ€” Move dynamic objects and functions inside your Effect](https://react.dev/learn/removing-effect-dependencies#move-dynamic-objects-and-functions-inside-your-effect) +> *"Object and function dependencies can make your Effect re-synchronize more often than you need."* + +```ts +// โŒ const options = { serverUrl, roomId }; +// useEffect(() => { connect(options); }, [options]); +// โœ… useEffect(() => { +// const options = { serverUrl, roomId }; +// connect(options); +// }, [roomId]); +``` + +### U12. Use useSyncExternalStore for External Store Subscriptions + +Browser APIs, third-party stores โ†’ use useSyncExternalStore instead of useState + useEffect. Prevents tearing in concurrent rendering + supports SSR server snapshots. + +> ๐Ÿ“– [You Might Not Need an Effect โ€” Subscribing to an external store](https://react.dev/learn/you-might-not-need-an-effect#subscribing-to-an-external-store) +> *"Although it's common to use Effects for this, React has a purpose-built Hook for subscribing to an external store that is preferred instead."* + +### U13. Notify Parents from Event Handlers + +When a child needs to notify a parent about state changes, call the parent's callback in the same event handler โ€” not in useEffect. Prevents cascading re-renders. + +> ๐Ÿ“– [You Might Not Need an Effect โ€” Notifying parent components about state changes](https://react.dev/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes) +> *"Delete the Effect and instead update the state of both components within the same event handler."* + +```ts +// โŒ useEffect(() => { onChange(isOn); }, [isOn]); +// โœ… function handleClick() { setIsOn(!isOn); onChange(!isOn); } +``` + +### U14. Async Effects Must Have Cleanup + +fetch/timer/subscription without cleanup โ†’ race condition. Fast prop changes cause older responses to overwrite newer ones. + +> ๐Ÿ“– [Synchronizing with Effects โ€” Fetching data](https://react.dev/learn/synchronizing-with-effects#fetching-data) +> *"If your Effect fetches something, the cleanup function should either abort the fetch or ignore its result."* + +```ts +useEffect(function fetchResults() { + let ignore = false; + fetchAPI(query).then(data => { if (!ignore) setResults(data); }); + return () => { ignore = true; }; +}, [query]); +``` + +--- + +## Memoization (2) + +### U15. useMemo Only for Measured Expensive Computations + +Measure with `console.time`. If under 1ms, useMemo overhead exceeds saved computation. + +> ๐Ÿ“– [useMemo โ€” How to tell if a calculation is expensive](https://react.dev/reference/react/useMemo#how-to-tell-if-a-calculation-is-expensive) +> *"If the overall logged time adds up to a significant amount (say, 1ms or more), it might make sense to memoize that calculation."* + +### U16. useCallback Only When Passing to memo()-Wrapped Children + +Stable reference to a non-memo() child has zero re-render prevention effect. + +> ๐Ÿ“– [useCallback](https://react.dev/reference/react/useCallback) +> *"You should only rely on useCallback as a performance optimization. If your code doesn't work without it, find the underlying problem and fix it first. Then you may add useCallback back."* + +--- + +## Hook Design (1) + +### U17. Extract Custom Hooks for Reusable Stateful Logic + +No lifecycle wrappers (`useMount`, `useEffectOnce`). Only purpose-specific hooks (`useWindowSize`, `useOnlineStatus`). +Extraction criterion: Does the same state+effect pattern repeat in 2+ components? + +> ๐Ÿ“– [Reusing Logic with Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) +> *"Custom Hooks let you share stateful logic, not state itself."* diff --git a/packages/plugin/.claude-plugin/plugin.json b/packages/plugin/.claude-plugin/plugin.json new file mode 100644 index 00000000..2d19549a --- /dev/null +++ b/packages/plugin/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "react-hook-philosophy", + "version": "0.1.0", + "description": "React hook design philosophy for code review and writing. Covers SSR safety, return values, state design, effect patterns, TypeScript, and performance.", + "author": { "name": "kimyouknow" }, + "homepage": "https://react-simplikit.slash.page", + "repository": "https://github.com/kimyouknow/react-simplikit", + "license": "MIT", + "keywords": ["react", "hooks", "philosophy", "code-review", "ssr", "typescript"] +} diff --git a/packages/plugin/LICENSE b/packages/plugin/LICENSE new file mode 100644 index 00000000..5737c5a3 --- /dev/null +++ b/packages/plugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kimyouknow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin/README.md b/packages/plugin/README.md new file mode 100644 index 00000000..a0a79443 --- /dev/null +++ b/packages/plugin/README.md @@ -0,0 +1,48 @@ +# react-hook-philosophy + +React hook design philosophy plugin for Claude Code. Two skills for reviewing and writing hooks with principled patterns. + +## Install + +```bash +claude plugin install --source git-subdir \ + --url https://github.com/kimyouknow/react-simplikit.git \ + --path packages/plugin +``` + +## Skills + +### /react-hook-review + +Review hooks against 31 design principles. Structured feedback with severity levels. + +- 14 coding principles (C1-C14): return values, SSR safety, TypeScript, cleanup, performance +- 17 usage patterns (U1-U17): state design, effect usage, memoization, hook design +- Output: Great Work / Required Changes / Suggestions / Next Steps + +### /react-hook-writing + +Write hooks following design philosophy. Themed guide with code examples. + +- State management patterns (derive, useRef vs useState, useReducer) +- Effect patterns (when to use, cleanup, external store subscription) +- TypeScript, performance, JSDoc templates +- Reference implementations in `patterns.md` + +## Principles Overview + +| Category | Count | Examples | +|----------|-------|---------| +| Coding (C1-C14) | 14 | Always return objects, SSR-safe init, no `any`, cleanup | +| State Design (U1-U7) | 7 | Derive don't sync, useRef for non-rendered, discriminated unions | +| Effect Usage (U8-U14) | 7 | Effects for sync only, no chains, key reset, async cleanup | +| Memoization (U15-U16) | 2 | useMemo >= 1ms, useCallback + memo() only | +| Hook Design (U17) | 1 | Extract reusable logic, not lifecycle wrappers | + +## Philosophy + +Every rule includes a "Why" explanation. Opinionated items are transparently marked with trade-offs. + +## License + +MIT diff --git a/packages/plugin/skills/react-hook-review/SKILL.md b/packages/plugin/skills/react-hook-review/SKILL.md new file mode 100644 index 00000000..edf69257 --- /dev/null +++ b/packages/plugin/skills/react-hook-review/SKILL.md @@ -0,0 +1,102 @@ +--- +description: Review React hooks against design philosophy. Checks return values, SSR safety, state design, effect usage, TypeScript patterns, and performance. +--- + +# React Hook Review + +Review hooks against coding principles and usage patterns. Report findings by severity. + +## Coding Principles Checklist + +### Required (11 items) + +1. **Return values (C1)** โ€” Always return objects, even for single values. `{ value }` not bare primitives. + Why: Named fields, order-independent, extensible without breaking changes. + +2. **SSR-safe init (C2)** โ€” `useState(FIXED)` + `useEffect(sync)`. No browser API in initializer. + Why: Server has no `window` โ€” crashes or hydration mismatch. + +3. **Cleanup (C3)** โ€” Every useEffect with side effects returns cleanup (listeners, timers, AbortController). + Why: Memory leaks. StrictMode double-mount exposes missing cleanup immediately. + +4. **No `any` (C4)** โ€” Use generics ``. Justified `eslint-disable` with comment is acceptable. + Why: `any` propagates and defeats the type system. + +5. **Named exports (C5)** โ€” No default exports. Tree-shaking + unambiguous imports. + +6. **Strict booleans (C6)** โ€” `== null` for nullish, `!== undefined` for distinction. No implicit `if (value)`. + Why: `0`, `""`, `false` are falsy โ€” silent bugs. + +7. **Object parameters (C7)** โ€” Hook params as object props, not positional args. + Why: Order-independent, self-documenting, extensible. + +8. **JSDoc 4-tag (C9)** โ€” @description + @param + @returns + @example on every public API. + Why: AI doc generation quality + IDE tooltips. + +9. **Performance (C10)** โ€” Throttle (16ms) for >30 events/sec, deduplicate unchanged, startTransition for non-urgent. + Only applies to high-frequency event hooks. + +10. **Zero deps (C12)** โ€” No runtime dependencies. peerDependencies only. + +11. **Dependency isolation (C13)** โ€” Inject external dependencies as parameters rather than importing them directly inside hooks. + Why: Testability + replaceability. + +### Recommended (3 items) + +12. **Guard clauses (C8)** โ€” Early return over nested if-else. Flat success path. + Trade-off: Stylistic preference with no functional impact. + +13. **Function keyword (C11)** โ€” `function` for declarations, arrows for inline callbacks only. + Trade-off: Consistent style, but arrow declarations are valid JS. + +14. **Named useEffect (C14)** โ€” `useEffect(function handleX() {...})` not arrows. + Why: "handleResize" vs "anonymous" in error stacks. Trade-off: more verbose. + +## Usage Patterns Checklist + +### State Design + +- **Derive, don't sync (U1)** โ€” Compute from props/state during render. No `useEffect` for derived values. +- **Don't mirror props (U2)** โ€” Use prop directly or name it `initialX`. +- **useRef for non-rendered (U3)** โ€” Interval IDs, flags, previous values. +- **Discriminated unions (U5)** โ€” Replace boolean combos with status union type. +- **IDs not objects (U6)** โ€” Store selected ID, derive object from list. +- **Group related state (U7)** โ€” Always-together values in one object. + +### Effect Usage + +- **Effects for sync only (U8)** โ€” External systems. Not event handling or data transforms. +- **No effect chains (U9)** โ€” Consolidate cascading setState into handlers/reducers. +- **Key reset (U10)** โ€” `key={id}` to remount, not useEffect to clear state. +- **Deps inside effect (U11)** โ€” Objects/functions used only in effect go inside it. +- **useSyncExternalStore (U12)** โ€” For browser API / external store subscriptions. +- **Parent notify in handler (U13)** โ€” Call parent callback in same event handler, not effect. +- **Async cleanup (U14)** โ€” `ignore` flag or AbortController for every async effect. + +### Memoization + +- **useMemo >= 1ms (U15)** โ€” Measure with console.time. Skip if < 1ms. +- **useCallback + memo() (U16)** โ€” Only when child is wrapped in memo(). Otherwise pointless. + +### Hook Design + +- **Extract logic, not lifecycle (U17)** โ€” No `useMount`. Purpose-specific hooks only. + +## Output Format + +### Great Work +- [What was done well] + +### Required Changes +1. **[C#/U#]** Issue description + - Current: `code` + - Suggested: `code` + - Why: [reason] + +### Suggestions +- [Non-blocking improvements] + +### Next Steps +1. Fix required changes +2. Run test suite +3. Commit diff --git a/packages/plugin/skills/react-hook-writing/SKILL.md b/packages/plugin/skills/react-hook-writing/SKILL.md new file mode 100644 index 00000000..6a361b1d --- /dev/null +++ b/packages/plugin/skills/react-hook-writing/SKILL.md @@ -0,0 +1,124 @@ +--- +description: Write React hooks following design philosophy. Covers naming, return values, SSR safety, state design, effect patterns, TypeScript, and performance. +--- + +# React Hook Writing Guide + +Design principles for writing React hooks. Each section covers What + Why. + +## 1. API Design + +**Return values (C1):** Always return objects. Even single values use `{ value }`. +Why: Named fields, order-independent, extensible without breaking changes. + +```ts +function useDebounce({ value, delay }: { value: T; delay: number }): { value: T } +function useToggle({ initial }: { initial?: boolean }): { value: boolean; toggle: () => void } +``` + +**Parameters (C7):** Object props, not positional. Order-independent + self-documenting. + +**Named exports (C5):** No default exports. + +## 2. SSR Safety + +**Fixed initial + useEffect sync (C2).** Never call browser APIs in useState initializer. + +```ts +const [width, setWidth] = useState(0); +useEffect(function syncWidth() { + setWidth(window.innerWidth); +}, []); +``` + +For client-only apps: conditional initializer `useState(() => { if (typeof window === 'undefined') return 0; ... })` is acceptable. + +## 3. State Design + +**Derive, don't sync (U1):** Compute from existing state during render. No useEffect for derived values. + +```ts +// Compute during render +const fullName = firstName + ' ' + lastName; +``` + +**useRef for non-rendered values (U3):** Interval IDs, flags, previous values. + +**useReducer for complex state (U4):** 3+ related states changing together. + +**Discriminated unions (U5):** Replace boolean combos with `type Status = 'idle' | 'loading' | 'done'`. + +**Don't mirror props (U2):** Use directly, or name `initialX`. + +**IDs not objects (U6), group related state (U7).** + +## 4. Effect Patterns + +**Effects for sync only (U8).** External systems (network, DOM, browser APIs). Not for event handling or data transforms. + +**No effect chains (U9).** Consolidate cascading setState into event handlers or reducers. + +**Key reset (U10):** `key={id}` to remount cleanly, not useEffect to clear state. + +**Deps inside effect (U11):** Objects/functions used only in effect โ€” define inside. + +**Parent notify in handler (U13):** Call parent callback in same event handler, not effect. + +**useSyncExternalStore (U12):** For browser API or third-party store subscriptions, prefer `useSyncExternalStore` over `useState` + `useEffect`. Prevents tearing in concurrent rendering and supports SSR server snapshots. + +## 5. Cleanup (C3) + +Every side effect needs cleanup. Three patterns: + +```ts +// Event listeners +return () => window.removeEventListener('resize', handler); + +// Async (AbortController) +const controller = new AbortController(); +return () => controller.abort(); + +// Timers +return () => clearInterval(id); +``` + +Async effects need ignore flags or AbortController to prevent race conditions (U14). + +## 6. Performance (C10) + +Apply only to >30 events/sec (scroll, resize, keyboard): +- **Throttle** at 16ms (60fps) +- **Deduplicate**: skip setState when value unchanged +- **startTransition**: expensive non-urgent computations + +**useMemo (U15):** Only for measured >= 1ms computations. +**useCallback (U16):** Only when passing to memo()-wrapped children. + +## 7. TypeScript + +- **Generics `` (C4):** No `any`. Justified eslint-disable with comment is acceptable. +- **`as const`** for tuple returns (if ever needed). +- **Strict booleans (C6):** `== null` for nullish, `!== undefined` for distinction. +- **Function keyword (C11):** For declarations. Arrows for inline callbacks. + +## 8. Documentation (C9) + +```ts +/** + * @description [One-line summary] + * @param {{ value: T; delay: number }} params - Hook parameters + * @returns {{ value: T }} Debounced value + * @example + * const { value } = useDebounce({ value: query, delay: 300 }); + */ +``` + +## 9. Dependencies + +- **Zero runtime deps (C12):** peerDependencies only. +- **Inject externals (C13):** Pass fetcher/client as param, don't import directly. +- **Extract hooks for reuse (U17):** Same state+effect in 2+ components? Extract. No lifecycle wrappers. + +## Reference + +See [patterns.md](references/patterns.md) for 3 complete hook implementations. diff --git a/packages/plugin/skills/react-hook-writing/references/patterns.md b/packages/plugin/skills/react-hook-writing/references/patterns.md new file mode 100644 index 00000000..5d2f9abb --- /dev/null +++ b/packages/plugin/skills/react-hook-writing/references/patterns.md @@ -0,0 +1,234 @@ +# Hook Implementation Patterns + +Three complete hooks demonstrating the design principles in practice. + +--- + +## 1. useToggle (Simple) + +Demonstrates: C1 (object return), C5 (named export), C7 (object params), C9 (JSDoc), C14 (named useEffect) + +```ts +import { useState, useCallback } from 'react'; + +/** + * @description Manages a boolean toggle state. + * @param {{ initial?: boolean }} params - Hook parameters + * @returns {{ value: boolean; toggle: () => void; setTrue: () => void; setFalse: () => void }} + * @example + * const { value: isOpen, toggle } = useToggle({ initial: false }); + * + */ +export function useToggle({ initial = false }: { initial?: boolean } = {}) { + const [value, setValue] = useState(initial); + + const toggle = useCallback(function toggle() { + setValue(prev => !prev); + }, []); + + const setTrue = useCallback(function setTrue() { + setValue(true); + }, []); + + const setFalse = useCallback(function setFalse() { + setValue(false); + }, []); + + return { value, toggle, setTrue, setFalse }; +} +``` + +### Anti-pattern comparison + +```ts +// Bad: tuple return (C1 violation) +export function useToggle(initial = false): [boolean, () => void] { + // Adding setTrue/setFalse later = breaking change for all consumers +} + +// Bad: default export (C5 violation) +export default function useToggle() { ... } + +// Bad: positional params (C7 violation) +export function useToggle(initial: boolean, onChange?: (v: boolean) => void) { ... } +``` + +--- + +## 2. useDebounce (Intermediate) + +Demonstrates: C1 (object return), C3 (cleanup), C4 (generic), C7 (object params), C9 (JSDoc), C11 (function keyword) + +```ts +import { useState, useEffect, useRef } from 'react'; + +/** + * @description Delays updating a value until after a specified period of inactivity. + * @param {{ value: T; delay: number }} params - The value to debounce and delay in ms + * @returns {{ value: T }} The debounced value + * @example + * const { value: debouncedQuery } = useDebounce({ value: searchQuery, delay: 300 }); + * useEffect(function fetchResults() { + * fetch(`/api/search?q=${debouncedQuery}`); + * }, [debouncedQuery]); + */ +export function useDebounce({ value, delay }: { value: T; delay: number }): { value: T } { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(function scheduleUpdate() { + const timer = setTimeout(function applyUpdate() { + setDebouncedValue(value); + }, delay); + + return function cancelPendingUpdate() { + clearTimeout(timer); + }; + }, [value, delay]); + + return { value: debouncedValue }; +} +``` + +### Anti-pattern comparison + +```ts +// Bad: no cleanup (C3 violation) โ€” timer leak on rapid value changes +useEffect(() => { + setTimeout(() => setDebouncedValue(value), delay); + // Missing: return () => clearTimeout(timer) +}, [value, delay]); + +// Bad: any type (C4 violation) +function useDebounce(value: any, delay: any): any { ... } +``` + +--- + +## 3. useMediaQuery (Complex โ€” SSR-Safe) + +Demonstrates: C1 (object return), C2 (SSR-safe), C3 (cleanup), C10 (performance), C14 (named effect) +Also: U3 (useRef for non-rendered), U8 (effect for external sync) + +```ts +import { useState, useEffect, useRef } from 'react'; + +/** + * @description Tracks whether a CSS media query matches. SSR-safe with fixed initial value. + * @param {{ query: string }} params - The media query string + * @returns {{ matches: boolean }} Whether the media query currently matches + * @example + * const { matches: isMobile } = useMediaQuery({ query: '(max-width: 768px)' }); + * return isMobile ? : ; + */ +export function useMediaQuery({ query }: { query: string }): { matches: boolean } { + const [matches, setMatches] = useState(false); // C2: Fixed initial value (SSR-safe) + const prevMatchesRef = useRef(false); // U3: Non-rendered value + + useEffect(function syncMediaQuery() { + if (typeof window === 'undefined') { + return; + } + + const mediaQueryList = window.matchMedia(query); + + function handleChange() { + const nextMatches = mediaQueryList.matches; + + // C10: Deduplicate โ€” skip if unchanged + if (prevMatchesRef.current === nextMatches) { + return; + } + prevMatchesRef.current = nextMatches; + setMatches(nextMatches); + } + + // Initial sync + handleChange(); + + // Subscribe + mediaQueryList.addEventListener('change', handleChange); + + return function cleanupMediaQuery() { + mediaQueryList.removeEventListener('change', handleChange); + }; + }, [query]); + + return { matches }; +} +``` + +### Anti-pattern comparison + +```ts +// Bad: SSR crash (C2 violation) +const [matches] = useState(window.matchMedia(query).matches); + +// Bad: no cleanup (C3 violation) โ€” listener leak +useEffect(() => { + const mql = window.matchMedia(query); + mql.addEventListener('change', handler); + // Missing: return () => mql.removeEventListener(...) +}, [query]); + +// Bad: no dedup โ€” unnecessary re-renders +function handleChange() { + setMatches(mediaQueryList.matches); // fires even when value unchanged +} +``` + +--- + +## SSR-Safe Hook Template + +Generic template for hooks that access browser APIs: + +```ts +export function useExample({ param }: { param: ParamType }): { value: ReturnType } { + const [value, setValue] = useState(FIXED_INITIAL); // C2: SSR-safe + const prevRef = useRef(FIXED_INITIAL); // U3: non-rendered + + useEffect(function syncBrowserValue() { + if (typeof window === 'undefined') { + return; + } + + // Initial sync + const current = getBrowserValue(param); + prevRef.current = current; + setValue(current); + + // Subscribe to changes + function handleChange() { + const next = getBrowserValue(param); + if (prevRef.current === next) { return; } // C10: dedup + prevRef.current = next; + setValue(next); + } + + window.addEventListener('event', handleChange); + + return function cleanup() { + window.removeEventListener('event', handleChange); + }; + }, [param]); + + return { value }; +} +``` + +--- + +## Anti-Pattern Collection + +| Anti-Pattern | Principle Violated | Fix | +|---|---|---| +| `useState(window.innerWidth)` | C2 (SSR) | `useState(0)` + useEffect sync | +| Missing cleanup on addEventListener | C3 (Cleanup) | Return removeEventListener | +| `function useData(url: any): any` | C4 (No any) | Use generic `` | +| `export default useHook` | C5 (Named exports) | `export function useHook` | +| `if (count)` where count can be 0 | C6 (Strict booleans) | `if (count != null)` | +| `useEffect(() => { setFullName(...) }, [first, last])` | U1 (Derive) | `const fullName = first + last` | +| `const [color] = useState(colorProp)` | U2 (Mirror props) | `const color = colorProp` | +| `const [id, setId] = useState(null)` for non-rendered | U3 (useRef) | `useRef(null)` | +| chained useEffects setting state | U9 (No chains) | Consolidate in handler | +| `useMemo(() => items.filter(...), [items])` on 20 items | U15 (Measure first) | Plain computation |