react-ops
React development patterns, hooks, state management, Server Components, and performance optimization. Use for: react, hooks, useState, useEffect, jsx, tsx, server components, RSC, zustand, react query, component patterns, react testing library, error boundary, suspense, react 19.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/react-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
React Operations
Comprehensive React skill covering hooks, component architecture, state management, Server Components, and performance optimization.
React 19 ecosystem facts verified as of 2026-07.
Hook Selection Decision Tree
What problem are you solving?
│
├─ Storing UI state that triggers re-renders
│ ├─ Simple value (string, number, boolean)
│ │ └─ useState
│ ├─ Complex state with multiple sub-values and logic
│ │ └─ useReducer (actions + reducer = predictable transitions)
│ └─ Derived from existing state
│ └─ Calculate inline or useMemo — not useState
│
├─ Referencing a value WITHOUT triggering re-render
│ ├─ DOM element reference
│ │ └─ useRef<HTMLElement>(null) + ref={ref}
│ └─ Mutable value (timer ID, previous value, counter)
│ └─ useRef (mutate ref.current directly)
│
├─ Running a side effect
│ ├─ After every render (or specific deps)
│ │ ├─ Needs cleanup (subscription, timer, abort)
│ │ │ └─ useEffect with return cleanup function
│ │ └─ No cleanup (logging, analytics)
│ │ └─ useEffect with empty or dep array
│ ├─ Before browser paint (DOM mutation, animation)
│ │ └─ useLayoutEffect
│ └─ Triggered by user action (not render)
│ └─ Call it directly in the event handler — not useEffect
│
├─ Caching an expensive computation
│ └─ useMemo(() => expensiveCalc(a, b), [a, b])
│
├─ Stable callback reference for child props / event handlers
│ └─ useCallback(() => doThing(dep), [dep])
│
├─ Reading shared context value
│ └─ useContext(MyContext)
│
├─ Generating stable unique ID (forms, aria)
│ └─ useId()
│
├─ Syncing external store (Redux, Zustand internals)
│ └─ useSyncExternalStore(subscribe, getSnapshot)
│
└─ React 19+
├─ Await a promise or read context
│ └─ use(promise | context)
├─ Form submit state (pending, data, action)
│ └─ useFormStatus / useActionState
└─ Optimistic UI before server response
└─ useOptimistic(state, updateFn)
Component Pattern Decision Tree
What's your composition challenge?
│
├─ Group of related components sharing implicit state
│ (Tabs, Accordion, Select, Menu)
│ └─ Compound Components with Context
│ Parent provides state via Context
│ Children consume via useContext
│
├─ Consumer needs to control rendering output
│ └─ Render Props: children(props) or render={fn}
│ Good for: headless UI, flexible layouts
│
├─ Apply cross-cutting concerns (auth, logging, theming)
│ to multiple components
│ └─ Higher-Order Components (HOC)
│ Wrap with withAuth(Component) or withLogging(Component)
│ Prefer custom hooks for pure logic
│
├─ Encapsulate reusable stateful logic
│ └─ Custom Hook — always prefer over HOC when possible
│ Composable, testable, no wrapper hell
│
├─ Need imperative control from parent (focus, scroll, reset)
│ └─ forwardRef + useImperativeHandle
│
├─ Render content outside DOM hierarchy (modal, tooltip, toast)
│ └─ Portal: createPortal(content, document.body)
│
├─ Accept arbitrary children/slots without prop drilling
│ └─ Slot pattern via children, or named props (header, footer)
│
└─ Polymorphic rendering (button that renders as <a> or div)
└─ as prop pattern with TypeScript generics
State Management Decision Tree
Where does this state live and who owns it?
│
├─ Only one component needs it
│ └─ useState or useReducer (local state)
│
├─ A few nearby components need it
│ └─ Lift state to nearest common ancestor + prop drilling
│ (2-3 levels is fine)
│
├─ Many components need it, rarely changes
│ (theme, locale, auth user)
│ └─ React Context API
│ Split contexts by update frequency
│ Avoid single giant context
│
├─ Global client state, changes often
│ (shopping cart, UI preferences, navigation)
│ ├─ Simple/small app → Zustand (minimal boilerplate)
│ ├─ Atomic updates, React Suspense integration → Jotai
│ └─ Large team, time-travel debugging, complex logic → Redux Toolkit
│
├─ Server state (remote data, cache, sync)
│ (API data, database queries)
│ └─ TanStack Query (React Query)
│ Handles: caching, background refetch, loading/error
│ Don't use useState + useEffect for server data
│
└─ Form state
└─ React Hook Form + Zod validation
(controlled inputs are fine for simple forms)
React 19 Quick Reference
| Feature | API | Purpose |
|---|---|---|
use() hook |
use(promise) / use(context) |
Await promises in render, read context conditionally |
| Actions | async function action(formData) |
Async transitions with built-in pending state |
useActionState |
useActionState(action, initialState) |
Action result + pending state |
useFormStatus |
useFormStatus() |
Pending/data/method inside form |
useOptimistic |
useOptimistic(state, updateFn) |
Optimistic UI before server response |
| React Compiler | Automatic memoization | Replaces most memo, useMemo, useCallback |
ref as prop |
<Input ref={ref}> |
No more forwardRef wrapper needed |
<Context> as provider |
<MyContext value={val}> |
No more <MyContext.Provider> |
// React 19: use() for data fetching in Server Components
import { use } from 'react';
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved
return <h1>{user.name}</h1>;
}
// React 19: useActionState
import { useActionState } from 'react';
function ContactForm() {
const [state, action, isPending] = useActionState(
async (prevState: State, formData: FormData) => {
const result = await submitContact(formData);
return result;
},
{ error: null }
);
return (
<form action={action}>
<input name="email" type="email" />
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
Server vs Client Components
Does this component need...?
│
├─ useState, useReducer, useContext
│ └─ Client Component ('use client')
│
├─ useEffect, useLayoutEffect
│ └─ Client Component ('use client')
│
├─ Browser APIs (window, document, localStorage)
│ └─ Client Component ('use client')
│
├─ Event handlers (onClick, onChange, onSubmit)
│ └─ Client Component ('use client')
│
├─ Third-party libraries that use hooks/browser APIs
│ └─ Client Component ('use client')
│
├─ Direct database/file system access
│ └─ Server Component (default, no directive)
│
├─ Access to env vars (server-only secrets)
│ └─ Server Component
│
├─ Large dependencies you want to keep off the client bundle
│ └─ Server Component
│
└─ async/await at the top level
└─ Server Component
Client boundary rules:
'use client'marks a boundary — everything imported below it becomes client JS- Server Components can import Client Components (they pass as props/children)
- Client Components CANNOT import Server Components directly
- Pass Server Component output as
childrenprop to Client Components - Server data → Client: pass as serializable props only (no functions, classes, DOM nodes)
Performance Checklist
| Technique | When to Use | When NOT to Use |
|---|---|---|
React.memo |
Component re-renders often with same props | Nearly everything — adds comparison overhead |
useMemo |
Expensive calculation (>1ms), stable dep array | Primitive values, simple expressions |
useCallback |
Callback passed to memoized child or in dep array | Inline handlers on DOM elements |
React.lazy + Suspense |
Large components not needed on initial load | Small components, SSR-critical content |
useTransition |
Non-urgent state updates (filtering, sorting) | Time-sensitive UI (typing, hover) |
useDeferredValue |
Derived expensive render from fast-changing value | Same as above |
| Virtualization | Lists >100 items | Small lists — overhead not worth it |
| React Compiler (v19) | Automatic — replaces most manual memoization | Opt-out with "use no memo" if needed |
Common Gotchas
| Gotcha | Why It Happens | Fix |
|---|---|---|
| Stale closure in useEffect | Callback captures old state/prop at definition time | Add value to dep array, or use functional update setState(prev => ...) |
| Missing useEffect dependency | Linter disabled or ignored, stale data shown | Never disable exhaustive-deps; use useCallback to stabilize functions |
| Index as list key | Keys change on reorder/insert, causing wrong component identity | Use stable unique ID from data (item.id) |
| Hydration mismatch | Server HTML doesn't match first client render | Avoid typeof window, random values, or dates in render; use useEffect for client-only content |
| Unnecessary re-renders from context | All consumers re-render when any context value changes | Split context by concern; memoize context value with useMemo |
| useEffect for derived state | State derived from another state causes extra render cycle | Compute derived value during render inline or with useMemo |
| Missing cleanup in useEffect | Memory leaks from subscriptions, timers, fetch requests | Always return cleanup function; use AbortController for fetch |
| Strict Mode double invocation | Effects run twice in dev to catch bugs | Design effects to be idempotent; cleanup must fully reverse effect |
| Controlled/uncontrolled switch | value prop toggling between defined and undefined |
Always provide defined value or always use defaultValue; never both |
| Object/array in dep array | New reference every render triggers effect repeatedly | Memoize with useMemo; use primitive values in deps where possible |
| Async function directly in useEffect | useEffect(() => async () => {}) returns a Promise, not cleanup |
Wrap: useEffect(() => { async function run() {...}; run(); }, []) |
Reference Files
| File | When to Load |
|---|---|
./references/hooks-patterns.md |
Deep hook usage: custom hooks, React 19 hooks, useEffect patterns, hook composition |
./references/component-architecture.md |
Compound components, HOC, render props, portals, forwardRef, polymorphic components |
./references/state-management.md |
Context API, Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form |
./references/server-components.md |
RSC architecture, Server Actions, Next.js App Router, caching, streaming, metadata |
./references/performance.md |
React.memo, code splitting, virtualization, React Compiler, Web Vitals, profiling |
./references/testing.md |
RTL queries, user-event, MSW, renderHook, Vitest setup, accessibility testing |
Staleness Verifier
This skill encodes fast-moving facts (the React 19 API surface, the ecosystem
package stack). scripts/check-react-facts.py
guards them against silent drift — internal consistency in PR CI, live
major-version drift in the scheduled freshness job:
# Structural (PR CI, no network): every catalogued package + React 19 gate is
# still named in this skill's prose, and the currency note still carries a year.
python3 skills/react-ops/scripts/check-react-facts.py --offline # exit 0 consistent, 10 drift
# Live (weekly freshness job, never blocks a PR): is any documented major
# now behind npm's latest dist-tag?
python3 skills/react-ops/scripts/check-react-facts.py --live # exit 10 a major moved ahead, 7 npm unreachable
The canonical fact list lives in assets/react-facts.json; when you add or drop a recommendation or the prose stops naming one, update it to match or --offline fails CI.
See Also
| Skill | When to Combine |
|---|---|
typescript-ops |
TypeScript generics with React props, discriminated unions for state machines, utility types |
testing-ops |
Test strategy, mocking patterns, CI integration, snapshot vs behavioral tests |
tailwind-ops |
CSS-in-JS alternatives, responsive design with Tailwind in React components |
javascript-ops |
Async patterns, Promises, generators, module system fundamentals |
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
react-facts.json 2.3 KB
{ "_comment": "Canonical fast-moving facts the react-ops skill encodes. scripts/check-react-facts.py asserts SKILL.md + references name these consistently (--offline) and probes the npm registry for major-version drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping. documented_major is the major the skill's prose commits to (react/react-dom/next) or the current tracked major for ecosystem libs the prose names without pinning a version.", "schema": "claude-mods.react-ops.facts/v1", "as_of": "2026-07-05", "react_major": 19, "version_gates": { "_comment": "React 19 APIs the skill centers on. --offline asserts each token is named in SKILL.md/references prose; a missing token means the skill stopped teaching a React 19 feature it claims.", "use": "use(", "actions": "Actions", "use_action_state": "useActionState", "use_form_status": "useFormStatus", "use_optimistic": "useOptimistic", "react_compiler": "React Compiler" }, "packages": { "react": { "documented_major": 19, "prose": ["react"], "role": "core (React 19)" }, "react-dom": { "documented_major": 19, "prose": ["react-dom"], "role": "DOM host (useFormStatus, createRoot)" }, "next": { "documented_major": 16, "prose": ["Next.js"], "role": "App Router / RSC host" }, "zustand": { "documented_major": 5, "prose": ["Zustand"], "role": "client state" }, "jotai": { "documented_major": 2, "prose": ["Jotai"], "role": "atomic client state" }, "@reduxjs/toolkit": { "documented_major": 2, "prose": ["Redux Toolkit"], "role": "complex client state" }, "@tanstack/react-query": { "documented_major": 5, "prose": ["TanStack Query"], "role": "server state" }, "react-hook-form": { "documented_major": 7, "prose": ["React Hook Form"], "role": "form state" }, "zod": { "documented_major": 4, "prose": ["Zod"], "role": "schema validation" }, "@testing-library/react": { "documented_major": 16, "prose": ["@testing-library/react"], "role": "component testing" }, "vitest": { "documented_major": 4, "prose": ["vitest"], "role": "test runner" } } }
-
-
references
-
component-architecture.md 15.6 KB
# Component Architecture Patterns for structuring React components: compound components, HOC, render props, portals, refs, and polymorphic components. --- ## Compound Components Compound components share implicit state through Context. The parent owns state; children consume it without prop drilling. ```tsx import { createContext, useContext, useState, ReactNode, KeyboardEvent, } from 'react'; // --- Types --- interface TabsContextValue { activeIndex: number; setActiveIndex: (index: number) => void; } // --- Context --- const TabsContext = createContext<TabsContextValue | null>(null); function useTabsContext() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('Tabs sub-components must be used within <Tabs>'); return ctx; } // --- Compound Components --- function Tabs({ children, defaultIndex = 0, }: { children: ReactNode; defaultIndex?: number; }) { const [activeIndex, setActiveIndex] = useState(defaultIndex); return ( <TabsContext.Provider value={{ activeIndex, setActiveIndex }}> <div className="tabs">{children}</div> </TabsContext.Provider> ); } function TabList({ children }: { children: ReactNode }) { return ( <div role="tablist" className="tab-list"> {children} </div> ); } function Tab({ children, index }: { children: ReactNode; index: number }) { const { activeIndex, setActiveIndex } = useTabsContext(); const isActive = activeIndex === index; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') setActiveIndex(index); }; return ( <button role="tab" aria-selected={isActive} tabIndex={isActive ? 0 : -1} onClick={() => setActiveIndex(index)} onKeyDown={handleKeyDown} className={isActive ? 'tab tab--active' : 'tab'} > {children} </button> ); } function TabPanels({ children }: { children: ReactNode }) { return <div className="tab-panels">{children}</div>; } function TabPanel({ children, index }: { children: ReactNode; index: number }) { const { activeIndex } = useTabsContext(); if (activeIndex !== index) return null; return ( <div role="tabpanel" className="tab-panel"> {children} </div> ); } // Attach as static properties Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panels = TabPanels; Tabs.Panel = TabPanel; // --- Usage --- function App() { return ( <Tabs defaultIndex={0}> <Tabs.List> <Tabs.Tab index={0}>Profile</Tabs.Tab> <Tabs.Tab index={1}>Settings</Tabs.Tab> </Tabs.List> <Tabs.Panels> <Tabs.Panel index={0}>Profile content</Tabs.Panel> <Tabs.Panel index={1}>Settings content</Tabs.Panel> </Tabs.Panels> </Tabs> ); } ``` --- ## Render Props Render props delegate rendering to the consumer. Use for headless/unstyled component libraries where the logic is fixed but appearance varies. ```tsx import { useState, ReactNode } from 'react'; interface ToggleRenderProps { on: boolean; toggle: () => void; setOn: (value: boolean) => void; } function Toggle({ initial = false, children, }: { initial?: boolean; children: (props: ToggleRenderProps) => ReactNode; }) { const [on, setOn] = useState(initial); return <>{children({ on, toggle: () => setOn(v => !v), setOn })}</>; } // Usage: consumer controls rendering function DarkModeButton() { return ( <Toggle> {({ on, toggle }) => ( <button onClick={toggle} aria-label={on ? 'Switch to light mode' : 'Switch to dark mode'} > {on ? '🌙' : '☀️'} </button> )} </Toggle> ); } // Prefer custom hooks over render props in modern React — // they achieve the same reuse with less JSX nesting function useToggle(initial = false) { const [on, setOn] = useState(initial); return { on, toggle: () => setOn(v => !v), setOn }; } ``` --- ## Higher-Order Components (HOC) HOCs wrap a component to inject props or add behavior. Prefer custom hooks for pure logic; use HOCs when you need to conditionally render or wrap JSX. ```tsx import { ComponentType, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; // --- Auth HOC --- interface WithAuthOptions { redirectTo?: string; } function withAuth<P extends object>( Component: ComponentType<P>, options: WithAuthOptions = {} ) { const { redirectTo = '/login' } = options; function AuthenticatedComponent(props: P) { const { user, isLoading } = useAuth(); const navigate = useNavigate(); useEffect(() => { if (!isLoading && !user) navigate(redirectTo); }, [user, isLoading, navigate]); if (isLoading) return <FullPageSpinner />; if (!user) return null; return <Component {...props} />; } // Preserve display name for DevTools AuthenticatedComponent.displayName = `withAuth(${Component.displayName ?? Component.name})`; return AuthenticatedComponent; } // Usage const ProtectedDashboard = withAuth(Dashboard); const AdminPanel = withAuth(AdminDashboard, { redirectTo: '/unauthorized' }); // --- Logging HOC --- function withLogging<P extends object>( Component: ComponentType<P>, componentName: string ) { function LoggedComponent(props: P) { useEffect(() => { console.log(`[Mount] ${componentName}`); return () => console.log(`[Unmount] ${componentName}`); }, []); return <Component {...props} />; } LoggedComponent.displayName = `withLogging(${componentName})`; return LoggedComponent; } ``` --- ## Controlled vs Uncontrolled Components ### Controlled ```tsx import { useState } from 'react'; // Controlled: parent owns and controls the value function ControlledInput({ value, onChange, label, }: { value: string; onChange: (value: string) => void; label: string; }) { return ( <label> {label} <input type="text" value={value} onChange={e => onChange(e.target.value)} /> </label> ); } function Parent() { const [name, setName] = useState(''); return <ControlledInput value={name} onChange={setName} label="Name" />; } ``` ### Uncontrolled with Imperative Handle ```tsx import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; interface InputHandle { focus: () => void; clear: () => void; getValue: () => string; } // Hybrid: uncontrolled internally, but exposes imperative API via ref const SmartInput = forwardRef<InputHandle, { defaultValue?: string }>( function SmartInput({ defaultValue = '' }, ref) { const inputRef = useRef<HTMLInputElement>(null); const [value, setValue] = useState(defaultValue); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => setValue(''), getValue: () => value, })); return ( <input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} /> ); } ); // Usage function Form() { const inputRef = useRef<InputHandle>(null); const handleSubmit = () => { const value = inputRef.current?.getValue(); if (!value?.trim()) { inputRef.current?.focus(); return; } submitForm(value); inputRef.current?.clear(); }; return ( <> <SmartInput ref={inputRef} defaultValue="" /> <button onClick={handleSubmit}>Submit</button> </> ); } ``` --- ## Error Boundaries Error boundaries must be class components. Use `react-error-boundary` package in production for less boilerplate. ```tsx import { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode); onError?: (error: Error, info: ErrorInfo) => void; } interface State { hasError: boolean; error: Error | null; } class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, info: ErrorInfo) { // Log to error tracking service (Sentry, Datadog, etc.) this.props.onError?.(error, info); console.error('ErrorBoundary caught:', error, info.componentStack); } reset = () => this.setState({ hasError: false, error: null }); render() { if (this.state.hasError && this.state.error) { const { fallback } = this.props; return typeof fallback === 'function' ? fallback(this.state.error, this.reset) : fallback; } return this.props.children; } } // Usage with error recovery function App() { return ( <ErrorBoundary fallback={(error, reset) => ( <div role="alert"> <h2>Something went wrong</h2> <p>{error.message}</p> <button onClick={reset}>Try Again</button> </div> )} onError={(error) => Sentry.captureException(error)} > <Dashboard /> </ErrorBoundary> ); } // react-error-boundary package (recommended for production) import { ErrorBoundary } from 'react-error-boundary'; function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void; }) { return ( <div role="alert"> <p>{error.message}</p> <button onClick={resetErrorBoundary}>Retry</button> </div> ); } <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => queryClient.clear()}> <App /> </ErrorBoundary> ``` --- ## Portals Portals render children into a DOM node outside the current React tree. Useful for modals, tooltips, and toasts that need to escape overflow/z-index constraints. ```tsx import { createPortal } from 'react-dom'; import { useEffect, useRef, ReactNode } from 'react'; function Modal({ isOpen, onClose, children, title, }: { isOpen: boolean; onClose: () => void; children: ReactNode; title: string; }) { const dialogRef = useRef<HTMLDialogElement>(null); // Trap focus and handle Escape key useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; if (isOpen) { dialog.showModal(); } else { dialog.close(); } }, [isOpen]); if (!isOpen) return null; // Renders outside current DOM tree, into document.body return createPortal( <dialog ref={dialogRef} aria-labelledby="modal-title" aria-modal="true" onClose={onClose} > <h2 id="modal-title">{title}</h2> <div>{children}</div> <button onClick={onClose} aria-label="Close modal"> × </button> </dialog>, document.body ); } ``` --- ## forwardRef ```tsx import { forwardRef, InputHTMLAttributes } from 'react'; interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label: string; error?: string; } // React 19: ref is now a regular prop, forwardRef not required // For React 18 and below: const Input = forwardRef<HTMLInputElement, InputProps>( function Input({ label, error, id, ...props }, ref) { const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-'); return ( <div className="input-wrapper"> <label htmlFor={inputId}>{label}</label> <input ref={ref} id={inputId} aria-describedby={error ? `${inputId}-error` : undefined} aria-invalid={!!error} {...props} /> {error && ( <span id={`${inputId}-error`} role="alert" className="error"> {error} </span> )} </div> ); } ); Input.displayName = 'Input'; // React 19 equivalent (no forwardRef needed): function InputV19({ label, ref, error, id, ...props }: InputProps & { ref?: React.Ref<HTMLInputElement>; }) { const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-'); return ( <div> <label htmlFor={inputId}>{label}</label> <input ref={ref} id={inputId} {...props} /> </div> ); } ``` --- ## Slot Pattern Named slots via props allow flexible composition without rigid component trees. ```tsx import { ReactNode } from 'react'; interface CardProps { header: ReactNode; children: ReactNode; footer?: ReactNode; aside?: ReactNode; } function Card({ header, children, footer, aside }: CardProps) { return ( <div className="card"> <div className="card__header">{header}</div> <div className="card__body"> <div className="card__content">{children}</div> {aside && <aside className="card__aside">{aside}</aside>} </div> {footer && <footer className="card__footer">{footer}</footer>} </div> ); } // Usage: consumer fills each slot independently function ProductCard({ product }: { product: Product }) { return ( <Card header={<img src={product.image} alt={product.name} />} footer={<AddToCartButton productId={product.id} />} aside={<ProductRating rating={product.rating} />} > <h3>{product.name}</h3> <p>{product.description}</p> </Card> ); } ``` --- ## Polymorphic Components (as prop) ```tsx import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'; // Generic polymorphic component type type PolymorphicProps<C extends ElementType, P = object> = { as?: C; children?: ReactNode; } & P & Omit<ComponentPropsWithoutRef<C>, keyof P | 'as' | 'children'>; // Button that can render as <button>, <a>, or any element function Button<C extends ElementType = 'button'>({ as, children, variant = 'primary', ...props }: PolymorphicProps<C, { variant?: 'primary' | 'secondary' | 'ghost' }>) { const Component = as ?? 'button'; return ( <Component className={`btn btn--${variant}`} {...props}> {children} </Component> ); } // Usage — TypeScript infers correct HTML attributes <Button onClick={() => {}}>Click me</Button> // renders <button> <Button as="a" href="/about">About</Button> // renders <a>, href is valid <Button as="a" href="/about" variant="secondary">Link</Button> ``` --- ## Container / Presentational Split Largely superseded by hooks, but useful when separating data-fetching from display for testing. ```tsx // Presentational: receives data as props, no fetching function UserListView({ users, isLoading, error, onDelete, }: { users: User[]; isLoading: boolean; error: Error | null; onDelete: (id: string) => void; }) { if (isLoading) return <Spinner />; if (error) return <ErrorMessage error={error} />; return ( <ul> {users.map(user => ( <li key={user.id}> {user.name} <button onClick={() => onDelete(user.id)}>Delete</button> </li> ))} </ul> ); } // Container: owns data-fetching, passes to presentational function UserListContainer() { const { data: users = [], isLoading, error } = useQuery(['users'], fetchUsers); const deleteMutation = useMutation(deleteUser, { onSuccess: () => queryClient.invalidateQueries(['users']), }); return ( <UserListView users={users} isLoading={isLoading} error={error ?? null} onDelete={id => deleteMutation.mutate(id)} /> ); } ``` --- ## Patterns to Avoid | Anti-pattern | Problem | Fix | |--------------|---------|-----| | Prop drilling past 3 levels | Hard to maintain, tightly coupled | Compound components or Context | | HOC for pure logic (no JSX needed) | Creates wrapper component unnecessarily | Custom hook instead | | Huge single component (500+ lines) | Hard to test, reuse, understand | Split by responsibility | | `any` in component props | Loses type safety | Type all props; use `unknown` with narrowing | | `key` on React.Fragment without need | Unnecessary | Only add key when rendering lists | | Mutable props | Breaks React's unidirectional data flow | Lift state or use callback | | Boolean props without clear intent | `<Input disabled />` vs `<Input disabled={false}>` | Always explicit: `disabled={isLoading}` | -
hooks-patterns.md 20.1 KB
# Hooks Patterns Deep reference for React hooks — built-in hooks, custom hook recipes, React 19 hooks, and composition patterns. --- ## useState ### Initializer Function (Lazy Initial State) When initial state is expensive to compute, pass a function — it runs only once. ```typescript import { useState } from 'react'; // BAD: parseExpensiveData runs on every render const [data, setData] = useState(parseExpensiveData(rawInput)); // GOOD: runs once at mount const [data, setData] = useState(() => parseExpensiveData(rawInput)); // GOOD: reading from localStorage (sync, only once) const [theme, setTheme] = useState<'light' | 'dark'>( () => (localStorage.getItem('theme') as 'light' | 'dark') ?? 'light' ); ``` ### Functional Updates When new state depends on previous state, always use the functional form to avoid stale closures. ```typescript function Counter() { const [count, setCount] = useState(0); // BAD: if called rapidly, `count` might be stale const increment = () => setCount(count + 1); // GOOD: always receives the latest state const increment = () => setCount(prev => prev + 1); // GOOD: batch multiple updates const incrementBy3 = () => { setCount(prev => prev + 1); setCount(prev => prev + 1); setCount(prev => prev + 1); }; return <button onClick={increment}>{count}</button>; } ``` ### Object State ```typescript interface FormState { name: string; email: string; age: number; } function ProfileForm() { const [form, setForm] = useState<FormState>({ name: '', email: '', age: 0, }); // Partial update pattern — spread to preserve other fields const updateField = <K extends keyof FormState>( key: K, value: FormState[K] ) => setForm(prev => ({ ...prev, [key]: value })); return ( <input value={form.name} onChange={e => updateField('name', e.target.value)} /> ); } ``` --- ## useReducer Use when state transitions are complex, involve multiple sub-values, or next state depends on previous in non-trivial ways. ```typescript import { useReducer } from 'react'; // 1. Define state shape interface CartState { items: CartItem[]; total: number; isCheckingOut: boolean; } // 2. Define discriminated union of actions type CartAction = | { type: 'ADD_ITEM'; payload: CartItem } | { type: 'REMOVE_ITEM'; payload: { id: string } } | { type: 'CLEAR_CART' } | { type: 'SET_CHECKOUT'; payload: boolean }; // 3. Reducer — pure function, no side effects function cartReducer(state: CartState, action: CartAction): CartState { switch (action.type) { case 'ADD_ITEM': return { ...state, items: [...state.items, action.payload], total: state.total + action.payload.price, }; case 'REMOVE_ITEM': { const removed = state.items.find(i => i.id === action.payload.id); return { ...state, items: state.items.filter(i => i.id !== action.payload.id), total: state.total - (removed?.price ?? 0), }; } case 'CLEAR_CART': return { items: [], total: 0, isCheckingOut: false }; case 'SET_CHECKOUT': return { ...state, isCheckingOut: action.payload }; default: // TypeScript exhaustiveness check action satisfies never; return state; } } const initialState: CartState = { items: [], total: 0, isCheckingOut: false }; function Cart() { const [state, dispatch] = useReducer(cartReducer, initialState); return ( <div> <p>Items: {state.items.length}</p> <p>Total: ${state.total}</p> <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>Clear</button> </div> ); } ``` --- ## useRef ### DOM Access ```typescript import { useRef, useEffect } from 'react'; function AutoFocusInput() { const inputRef = useRef<HTMLInputElement>(null); useEffect(() => { // ref.current is the DOM node after mount inputRef.current?.focus(); }, []); return <input ref={inputRef} placeholder="Auto-focused" />; } ``` ### Mutable Value (No Re-render) ```typescript function Stopwatch() { const [elapsed, setElapsed] = useState(0); // Store timer ID without triggering re-renders const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const start = () => { if (intervalRef.current !== null) return; intervalRef.current = setInterval(() => { setElapsed(prev => prev + 1); }, 1000); }; const stop = () => { if (intervalRef.current === null) return; clearInterval(intervalRef.current); intervalRef.current = null; }; // Clean up on unmount useEffect(() => () => stop(), []); return ( <div> <p>{elapsed}s</p> <button onClick={start}>Start</button> <button onClick={stop}>Stop</button> </div> ); } ``` --- ## useEffect ### Cleanup Pattern Every subscription, timer, or fetch should have a cleanup. ```typescript import { useEffect, useState } from 'react'; // Pattern: subscription with cleanup function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight, }); useEffect(() => { const handler = () => { setSize({ width: window.innerWidth, height: window.innerHeight }); }; window.addEventListener('resize', handler); // Cleanup removes listener — runs before next effect and on unmount return () => window.removeEventListener('resize', handler); }, []); // empty array = run once at mount return size; } ``` ### Async in useEffect ```typescript useEffect(() => { // WRONG: async function returns Promise, not cleanup // useEffect(async () => { ... }, []); // CORRECT: define async function, call it immediately const controller = new AbortController(); async function fetchData() { try { const res = await fetch(`/api/users/${userId}`, { signal: controller.signal, }); const data = await res.json(); setUser(data); } catch (err) { if (err instanceof Error && err.name !== 'AbortError') { setError(err); } } } fetchData(); // Abort in-flight request if userId changes or component unmounts return () => controller.abort(); }, [userId]); ``` ### useLayoutEffect vs useEffect ```typescript import { useLayoutEffect, useEffect, useRef } from 'react'; // useLayoutEffect: fires synchronously AFTER DOM mutations, BEFORE paint // Use for: measuring DOM, preventing visual flicker function Tooltip({ anchorRef }: { anchorRef: React.RefObject<HTMLElement> }) { const tooltipRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { // Measure anchor position and position tooltip BEFORE browser paints const anchor = anchorRef.current; const tooltip = tooltipRef.current; if (!anchor || !tooltip) return; const rect = anchor.getBoundingClientRect(); tooltip.style.top = `${rect.bottom + 8}px`; tooltip.style.left = `${rect.left}px`; }); return <div ref={tooltipRef} className="tooltip">Tooltip</div>; } // useEffect: fires asynchronously AFTER paint // Use for: data fetching, subscriptions, analytics — anything that doesn't // need to block the browser paint ``` --- ## Custom Hooks ### useFetch with AbortController ```typescript import { useState, useEffect, useCallback } from 'react'; interface FetchState<T> { data: T | null; error: Error | null; isLoading: boolean; } function useFetch<T>(url: string) { const [state, setState] = useState<FetchState<T>>({ data: null, error: null, isLoading: true, }); const refetch = useCallback(() => { const controller = new AbortController(); setState(prev => ({ ...prev, isLoading: true, error: null })); fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }) .then(data => setState({ data, error: null, isLoading: false })) .catch(err => { if (err.name !== 'AbortError') { setState({ data: null, error: err, isLoading: false }); } }); return () => controller.abort(); }, [url]); useEffect(() => { const cleanup = refetch(); return cleanup; }, [refetch]); return { ...state, refetch }; } // Usage function UserProfile({ id }: { id: string }) { const { data, error, isLoading, refetch } = useFetch<User>(`/api/users/${id}`); if (isLoading) return <Spinner />; if (error) return <Error message={error.message} onRetry={refetch} />; return <div>{data?.name}</div>; } ``` ### useLocalStorage (SSR-safe) ```typescript import { useState, useEffect, useCallback } from 'react'; function useLocalStorage<T>(key: string, initialValue: T) { // Read from localStorage with SSR safety const readValue = useCallback((): T => { if (typeof window === 'undefined') return initialValue; try { const item = window.localStorage.getItem(key); return item ? (JSON.parse(item) as T) : initialValue; } catch { console.warn(`Error reading localStorage key "${key}"`); return initialValue; } }, [key, initialValue]); const [storedValue, setStoredValue] = useState<T>(readValue); const setValue = useCallback( (value: T | ((val: T) => T)) => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== 'undefined') { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch { console.warn(`Error setting localStorage key "${key}"`); } }, [key, storedValue] ); // Sync across tabs useEffect(() => { const handleStorageChange = (event: StorageEvent) => { if (event.key === key) { setStoredValue(readValue()); } }; window.addEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange); }, [key, readValue]); return [storedValue, setValue] as const; } ``` ### useDebounce ```typescript import { useState, useEffect } from 'react'; function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // Usage: debounce search input before firing API call function SearchBar() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); useEffect(() => { if (debouncedQuery) { searchApi(debouncedQuery); } }, [debouncedQuery]); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." /> ); } ``` ### useMediaQuery ```typescript import { useState, useEffect } from 'react'; function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState<boolean>(() => { if (typeof window === 'undefined') return false; return window.matchMedia(query).matches; }); useEffect(() => { if (typeof window === 'undefined') return; const mql = window.matchMedia(query); const handler = (e: MediaQueryListEvent) => setMatches(e.matches); // Use addEventListener (deprecated addListener removed in modern browsers) mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }, [query]); return matches; } // Predefined breakpoints matching Tailwind defaults export const useIsTablet = () => useMediaQuery('(min-width: 768px)'); export const useIsDesktop = () => useMediaQuery('(min-width: 1024px)'); export const usePrefersDark = () => useMediaQuery('(prefers-color-scheme: dark)'); export const usePrefersReducedMotion = () => useMediaQuery('(prefers-reduced-motion: reduce)'); ``` ### useIntersectionObserver ```typescript import { useEffect, useRef, useState } from 'react'; interface UseIntersectionOptions extends IntersectionObserverInit { freezeOnceVisible?: boolean; } function useIntersectionObserver(options: UseIntersectionOptions = {}) { const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = options; const elementRef = useRef<HTMLElement>(null); const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null); const frozen = entry?.isIntersecting && freezeOnceVisible; useEffect(() => { const element = elementRef.current; if (!element || frozen) return; const observer = new IntersectionObserver( ([entry]) => setEntry(entry), { threshold, root, rootMargin } ); observer.observe(element); return () => observer.disconnect(); }, [threshold, root, rootMargin, frozen]); return { ref: elementRef, entry, isIntersecting: !!entry?.isIntersecting }; } // Usage: lazy load images function LazyImage({ src, alt }: { src: string; alt: string }) { const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.1, freezeOnceVisible: true, }); return ( <div ref={ref as React.RefObject<HTMLDivElement>} style={{ minHeight: 200 }}> {isIntersecting && <img src={src} alt={alt} loading="lazy" />} </div> ); } ``` ### usePrevious ```typescript import { useRef, useEffect } from 'react'; function usePrevious<T>(value: T): T | undefined { const ref = useRef<T | undefined>(undefined); // Runs after render — ref holds value from previous render useEffect(() => { ref.current = value; }, [value]); // Returns value from before this render return ref.current; } // Usage: animate on value change function AnimatedCounter({ count }: { count: number }) { const prevCount = usePrevious(count); const direction = prevCount !== undefined && count > prevCount ? 'up' : 'down'; return ( <span className={`animate-${direction}`}> {count} </span> ); } ``` ### useEventListener ```typescript import { useEffect, useRef } from 'react'; function useEventListener<K extends keyof WindowEventMap>( eventType: K, handler: (event: WindowEventMap[K]) => void, element: EventTarget = window ): void { // Use ref so handler changes don't cause re-subscription const handlerRef = useRef(handler); useEffect(() => { handlerRef.current = handler; }); useEffect(() => { const listener = (event: Event) => handlerRef.current(event as WindowEventMap[K]); element.addEventListener(eventType, listener); return () => element.removeEventListener(eventType, listener); }, [eventType, element]); } // Usage function KeyboardShortcut() { useEventListener('keydown', event => { if (event.key === 'Escape') closeModal(); if ((event.metaKey || event.ctrlKey) && event.key === 'k') openSearch(); }); } ``` --- ## Hook Composition Build complex hooks by composing simpler ones. Each hook should do one thing well. ```typescript // Compose useFetch + useDebounce for a search hook function useSearch<T>(endpoint: string) { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); // Only fetch when query is non-empty const url = debouncedQuery ? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}` : null; const { data, isLoading, error } = useFetch<T[]>(url ?? ''); return { query, setQuery, results: data ?? [], isLoading: isLoading && !!debouncedQuery, error, }; } // Compose local storage + media query for responsive theme function useTheme() { const prefersDark = useMediaQuery('(prefers-color-scheme: dark)'); const [savedTheme, setSavedTheme] = useLocalStorage<'light' | 'dark' | 'system'>( 'theme', 'system' ); const resolvedTheme: 'light' | 'dark' = savedTheme === 'system' ? (prefersDark ? 'dark' : 'light') : savedTheme; return { theme: resolvedTheme, savedTheme, setTheme: setSavedTheme }; } ``` --- ## Rules of Hooks Only call hooks at the top level of a React function component or another custom hook. Never inside conditions, loops, or nested functions. ```typescript // VIOLATION: conditional hook call function BadComponent({ isLoggedIn }: { isLoggedIn: boolean }) { if (isLoggedIn) { const user = useUser(); // ERROR: conditional } } // FIX: always call hooks, conditionally use their values function GoodComponent({ isLoggedIn }: { isLoggedIn: boolean }) { const user = useUser(); if (!isLoggedIn) return null; return <div>{user.name}</div>; } // VIOLATION: hook in a loop function BadList({ ids }: { ids: string[] }) { return ids.map(id => { const data = useFetch(`/api/${id}`); // ERROR: in loop return <Item key={id} data={data} />; }); } // FIX: move hook logic into a child component function GoodList({ ids }: { ids: string[] }) { return ids.map(id => <ListItem key={id} id={id} />); } function ListItem({ id }: { id: string }) { const data = useFetch(`/api/${id}`); // CORRECT: top level return <Item data={data} />; } ``` --- ## React 19 Hooks ### use() — Promises and Context ```typescript import { use, Suspense } from 'react'; // Await a promise directly in render (must be wrapped in Suspense) async function fetchUser(id: string): Promise<User> { const res = await fetch(`/api/users/${id}`); return res.json(); } function UserCard({ userPromise }: { userPromise: Promise<User> }) { // Suspends until promise resolves; throws on rejection (ErrorBoundary handles it) const user = use(userPromise); return <div>{user.name}</div>; } function Page({ id }: { id: string }) { const userPromise = fetchUser(id); // start fetch, pass promise down return ( <Suspense fallback={<Skeleton />}> <UserCard userPromise={userPromise} /> </Suspense> ); } // use() can also read context conditionally (unlike useContext) function ConditionalTheme({ showLabel }: { showLabel: boolean }) { if (!showLabel) return null; const theme = use(ThemeContext); // conditional — allowed with use() return <span style={{ color: theme.primary }}>Label</span>; } ``` ### useFormStatus ```typescript import { useFormStatus } from 'react-dom'; // Must be used inside a <form> with an action function SubmitButton() { const { pending, data, method } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? 'Saving...' : 'Save'} </button> ); } function ProfileForm() { return ( <form action={updateProfileAction}> <input name="bio" /> <SubmitButton /> {/* useFormStatus works here */} </form> ); } ``` ### useOptimistic ```typescript import { useOptimistic, useTransition } from 'react'; interface Message { id: string; text: string; sending?: boolean; } function MessageList({ messages }: { messages: Message[] }) { const [optimisticMessages, addOptimisticMessage] = useOptimistic( messages, // Reducer: how to merge optimistic update into current state (currentMessages, newMessage: Message) => [ ...currentMessages, { ...newMessage, sending: true }, ] ); async function sendMessage(formData: FormData) { const text = formData.get('text') as string; const tempMessage = { id: crypto.randomUUID(), text }; // Update UI immediately addOptimisticMessage(tempMessage); // Send to server (optimistic update reverts on error) await saveMessage(text); } return ( <> {optimisticMessages.map(msg => ( <div key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}> {msg.text} </div> ))} <form action={sendMessage}> <input name="text" /> <button type="submit">Send</button> </form> </> ); } ``` --- ## Anti-patterns | Anti-pattern | Problem | Fix | |--------------|---------|-----| | `useEffect` with no dep array syncing props to state | Runs every render | Compute derived value during render | | Calling hooks from event handlers | Violates rules of hooks | Move hook to component top level | | `useState` for server data | Manual loading/error state, stale data | Use TanStack Query | | Large single `useEffect` doing multiple things | Hard to reason about, wrong deps | Split into separate `useEffect` calls per concern | | `useCallback` on everything | Adds overhead, no benefit without memoized children | Only when callback is a dep or passed to `memo` component | | Forgetting cleanup | Memory leaks, stale updates on unmounted component | Always return cleanup from `useEffect` | -
performance.md 17.6 KB
# Performance React performance patterns: memoization, code splitting, virtualization, React Compiler, profiling, and Web Vitals. --- ## Memoization ### React.memo Skips re-render when props haven't changed (shallow equality by default). ```tsx import { memo, useCallback, useState } from 'react'; interface ListItemProps { item: { id: string; name: string; count: number }; onDelete: (id: string) => void; } // Memoize expensive list items so parent re-renders don't cascade const ListItem = memo(function ListItem({ item, onDelete }: ListItemProps) { console.log(`Rendering ${item.name}`); // only logs when item or onDelete changes return ( <li> {item.name} ({item.count}) <button onClick={() => onDelete(item.id)}>Delete</button> </li> ); }); // Custom comparison — return true to SKIP re-render const ExpensiveChart = memo( function ExpensiveChart({ data, config }: ChartProps) { return <Canvas data={data} config={config} />; }, (prevProps, nextProps) => { // Only re-render if data length changes or config changes return ( prevProps.data.length === nextProps.data.length && prevProps.config.type === nextProps.config.type ); } ); // Parent must stabilize callbacks with useCallback to benefit from memo function ItemList({ items }: { items: Item[] }) { const [filter, setFilter] = useState(''); // Without useCallback, new function reference every render → memo is useless const handleDelete = useCallback((id: string) => { deleteItem(id); }, []); // stable — no deps return ( <ul> {items.map(item => ( <ListItem key={item.id} item={item} onDelete={handleDelete} /> ))} </ul> ); } ``` ### When NOT to Use React.memo ```tsx // BAD: memo on a component that almost always re-renders anyway const SimpleDiv = memo(({ children }: { children: React.ReactNode }) => ( <div>{children}</div> )); // BAD: memo where props contain new objects/arrays every render function Parent() { return ( // options is a new array every render — memo never skips <MemoizedChild options={['a', 'b', 'c']} /> ); } // GOOD: only memo when: // 1. Component renders the same output given the same props // 2. Re-renders frequently with same props (large lists, heavy computation) // 3. Props are primitives or stable references ``` ### useMemo ```tsx import { useMemo, useState } from 'react'; function ProductList({ products }: { products: Product[] }) { const [sortBy, setSortBy] = useState<'price' | 'name'>('name'); const [filter, setFilter] = useState(''); // Expensive: filter + sort on every render without memoization const processedProducts = useMemo(() => { const filtered = products.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()) ); return filtered.sort((a, b) => sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name) ); }, [products, filter, sortBy]); // only recalculates when these change return ( <ul> {processedProducts.map(p => <ProductCard key={p.id} product={p} />)} </ul> ); } // When NOT to use useMemo function BadUsage() { // BAD: simple operations don't need memoization — the overhead costs more const doubled = useMemo(() => count * 2, [count]); const greeting = useMemo(() => `Hello, ${name}`, [name]); // GOOD: compute inline const doubled = count * 2; const greeting = `Hello, ${name}`; } ``` ### useCallback ```tsx import { useCallback, useState, memo } from 'react'; // useCallback returns a stable function reference // Only useful when passed to: memo() components, useEffect dep arrays, other callbacks function SearchPage() { const [query, setQuery] = useState(''); const [results, setResults] = useState<Result[]>([]); // Stable reference: won't cause SearchResults to re-render when SearchPage renders const handleResultClick = useCallback((id: string) => { trackClick(id); // does not depend on any state }, []); // Correct deps: includeArchived is used inside the callback const [includeArchived, setIncludeArchived] = useState(false); const search = useCallback(async (q: string) => { const data = await fetchResults(q, { includeArchived }); setResults(data); }, [includeArchived]); // re-created when includeArchived changes return ( <> <SearchInput value={query} onChange={setQuery} onSearch={search} /> <MemoizedResults results={results} onResultClick={handleResultClick} /> </> ); } ``` --- ## Code Splitting ### React.lazy + Suspense ```tsx import { lazy, Suspense, useState } from 'react'; // Dynamic import — loaded only when rendered const HeavyEditor = lazy(() => import('./HeavyEditor')); const DataVizChart = lazy(() => import('./DataVizChart')); // Preload on hover for instant perceived load function preloadEditor() { const promise = import('./HeavyEditor'); return promise; } function Dashboard() { const [showEditor, setShowEditor] = useState(false); return ( <div> <button onClick={() => setShowEditor(true)} onMouseEnter={preloadEditor} // start loading before click > Open Editor </button> {showEditor && ( <Suspense fallback={<EditorSkeleton />}> <HeavyEditor /> </Suspense> )} <Suspense fallback={<ChartSkeleton />}> <DataVizChart /> </Suspense> </div> ); } ``` ### Route-Based Splitting (React Router) ```tsx import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; // Each route is its own chunk const HomePage = lazy(() => import('./pages/Home')); const DashboardPage = lazy(() => import('./pages/Dashboard')); const SettingsPage = lazy(() => import('./pages/Settings')); function App() { return ( <Suspense fallback={<PageLoader />}> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/dashboard" element={<DashboardPage />} /> <Route path="/settings" element={<SettingsPage />} /> </Routes> </Suspense> ); } ``` --- ## Avoiding Re-renders ### State Colocation ```tsx // BAD: state in parent causes all children to re-render function Parent() { const [inputValue, setInputValue] = useState(''); return ( <> <input value={inputValue} onChange={e => setInputValue(e.target.value)} /> <ExpensiveComponent /> {/* re-renders on every keystroke! */} <AnotherExpensiveComponent /> </> ); } // GOOD: colocate state where it's needed function InputSection() { const [inputValue, setInputValue] = useState(''); return <input value={inputValue} onChange={e => setInputValue(e.target.value)} />; } function Parent() { return ( <> <InputSection /> {/* only this re-renders */} <ExpensiveComponent /> {/* never re-renders */} <AnotherExpensiveComponent /> </> ); } ``` ### Children Pattern ```tsx // BAD: wrapping component re-renders on every parent render function Wrapper() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>{count}</button> <SlowComponent /> {/* re-renders even though it doesn't use count */} </div> ); } // GOOD: pass slow component as children — it's created in parent, not re-rendered function WrapperWithChildren({ children }: { children: React.ReactNode }) { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>{count}</button> {children} {/* reference is stable, SlowComponent doesn't re-render */} </div> ); } function App() { return ( <WrapperWithChildren> <SlowComponent /> </WrapperWithChildren> ); } ``` --- ## Concurrent Features ### useTransition ```tsx import { useState, useTransition } from 'react'; function FilterableList({ items }: { items: Item[] }) { const [filter, setFilter] = useState(''); const [filteredItems, setFilteredItems] = useState(items); const [isPending, startTransition] = useTransition(); const handleFilterChange = (value: string) => { // Urgent: update input immediately setFilter(value); // Non-urgent: defer the expensive filtering startTransition(() => { const filtered = items.filter(item => item.name.toLowerCase().includes(value.toLowerCase()) ); setFilteredItems(filtered); }); }; return ( <> <input value={filter} onChange={e => handleFilterChange(e.target.value)} placeholder="Filter..." /> {/* Show stale content with opacity while pending */} <ul style={{ opacity: isPending ? 0.7 : 1 }}> {filteredItems.map(item => <li key={item.id}>{item.name}</li>)} </ul> </> ); } ``` ### useDeferredValue ```tsx import { useState, useDeferredValue, memo } from 'react'; // useDeferredValue: defer a value derived from props/state // Unlike useTransition, works when you don't own the state setter function SearchResults({ query }: { query: string }) { // Defer the slow part — input stays responsive const deferredQuery = useDeferredValue(query); return ( <div style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}> <SlowResultsList query={deferredQuery} /> </div> ); } // Must be memoized for useDeferredValue to have effect const SlowResultsList = memo(function SlowResultsList({ query }: { query: string }) { // Expensive rendering — now deferred const results = heavySearch(query); return results.map(r => <Result key={r.id} result={r} />); }); ``` --- ## Virtualization For lists with more than 100 items, only render what's visible. ```tsx import { useVirtualizer } from '@tanstack/react-virtual'; import { useRef } from 'react'; function VirtualList({ items }: { items: Item[] }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 60, // estimated row height in px overscan: 5, // render 5 extra items outside viewport }); return ( // Scrollable container — must have a fixed height <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}> {/* Total height spacer so scrollbar is sized correctly */} <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}> {virtualizer.getVirtualItems().map(virtualItem => ( <div key={virtualItem.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)`, }} > <ListItem item={items[virtualItem.index]} /> </div> ))} </div> </div> ); } // Grid virtualizer function VirtualGrid({ items, columnCount = 3 }: { items: Item[]; columnCount?: number }) { const parentRef = useRef<HTMLDivElement>(null); const rowCount = Math.ceil(items.length / columnCount); const rowVirtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => parentRef.current, estimateSize: () => 200, }); const columnVirtualizer = useVirtualizer({ horizontal: true, count: columnCount, getScrollElement: () => parentRef.current, estimateSize: () => 300, }); return ( <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}> <div style={{ height: rowVirtualizer.getTotalSize(), width: columnVirtualizer.getTotalSize(), position: 'relative', }} > {rowVirtualizer.getVirtualItems().map(row => columnVirtualizer.getVirtualItems().map(col => { const index = row.index * columnCount + col.index; if (index >= items.length) return null; return ( <div key={`${row.key}-${col.key}`} style={{ position: 'absolute', top: row.start, left: col.start, width: col.size, height: row.size, }} > <GridItem item={items[index]} /> </div> ); }) )} </div> </div> ); } ``` --- ## React Compiler (React 19) The React Compiler automatically applies memoization — most manual `memo`, `useMemo`, and `useCallback` calls become unnecessary. ```tsx // Before React Compiler — manual memoization const ExpensiveList = memo(function ExpensiveList({ items, onDelete }: Props) { const sorted = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items]); const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]); return sorted.map(item => <Item key={item.id} item={item} onDelete={handleDelete} />); }); // After React Compiler — compiler adds memoization automatically function ExpensiveList({ items, onDelete }: Props) { const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name)); return sorted.map(item => <Item key={item.id} item={item} onDelete={onDelete} />); } // Opt out specific components if compiler breaks them function ProblematicComponent() { "use no memo"; // ... compiler skips this component } ``` ### Enabling React Compiler (Next.js) ```javascript // next.config.js const nextConfig = { experimental: { reactCompiler: true, }, }; // babel.config.js (for non-Next.js setups) module.exports = { plugins: [['babel-plugin-react-compiler', {}]], }; ``` --- ## Bundle Analysis ```bash # Next.js bundle analyzer npm install @next/bundle-analyzer # next.config.js const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({}); # Run ANALYZE=true npm run build ``` ```bash # source-map-explorer (framework-agnostic) npm install --save-dev source-map-explorer npx source-map-explorer 'build/static/js/*.js' ``` --- ## React DevTools Profiler ```tsx // Mark component interactions for DevTools import { Profiler } from 'react'; function onRenderCallback( id: string, // component tree id phase: 'mount' | 'update', actualDuration: number, // time spent rendering baseDuration: number, // estimated full render time startTime: number, commitTime: number ) { if (actualDuration > 16) { // flag renders > 1 frame (16ms) console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`); } } function App() { return ( <Profiler id="Dashboard" onRender={onRenderCallback}> <Dashboard /> </Profiler> ); } ``` --- ## Web Vitals | Metric | Meaning | React Impact | Target | |--------|---------|-------------|--------| | LCP (Largest Contentful Paint) | When main content loads | Large component trees, unoptimized images | < 2.5s | | FID / INP (Interaction to Next Paint) | Response time to user input | Long tasks blocking main thread | < 200ms | | CLS (Cumulative Layout Shift) | Visual stability | Dynamic content without reserved space | < 0.1 | | TTFB (Time to First Byte) | Server response time | RSC data fetching efficiency | < 800ms | ```tsx // Measure Web Vitals in Next.js // app/layout.tsx export function reportWebVitals(metric: NextWebVitalsMetric) { if (metric.label === 'web-vital') { // Send to analytics analytics.track('web_vital', { name: metric.name, value: metric.value, rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' }); } } // Avoiding CLS: always reserve space for dynamic content function Avatar({ src }: { src: string }) { return ( // Fixed dimensions prevent layout shift when image loads <div style={{ width: 40, height: 40 }}> <img src={src} width={40} height={40} alt="" /> </div> ); } ``` --- ## Image Optimization ```tsx import Image from 'next/image'; // Optimized image with automatic WebP conversion, lazy loading, CLS prevention function ProductImage({ product }: { product: Product }) { return ( <div style={{ position: 'relative', aspectRatio: '16/9' }}> <Image src={product.imageUrl} alt={product.name} fill // fills parent container sizes="(max-width: 768px) 100vw, 50vw" // responsive sizes hint priority={false} // true for above-fold LCP images placeholder="blur" // or "empty" blurDataURL={product.blurDataUrl} /> </div> ); } // LCP image — must be priority function HeroImage() { return ( <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority // preload this image — no lazy loading /> ); } ``` --- ## Performance Anti-patterns | Anti-pattern | Problem | Fix | |--------------|---------|-----| | `memo` on everything | Comparison overhead, false optimization | Profile first; only memo when re-renders are measured problem | | `useMemo` for cheap computations | Overhead of memoization > cost of computation | Only memoize if computation takes >1ms | | `useCallback` without memoized consumers | Stable reference with no benefit | Only use when callback is dep in `useEffect` or passed to `memo` component | | No `key` strategy for lists | React unmounts/remounts on reorder | Stable unique IDs from data | | Inline object/array props on `memo` components | New reference every render defeats memo | `useMemo` the value or move outside component | | Not virtualizing long lists | Renders thousands of DOM nodes | Use `@tanstack/react-virtual` for 100+ items | | All JS in single bundle | Slow initial load | Route-based code splitting with `lazy` | | `useEffect` polling instead of WebSocket/SSE | Constant network requests | Switch to real-time transport | | Importing full lodash/moment | Huge bundle impact | Use tree-shakeable alternatives or native APIs | -
server-components.md 12.4 KB
# Server Components React Server Components (RSC), Server Actions, Next.js App Router patterns, caching, and streaming. --- ## RSC Architecture Server Components render on the server and send HTML (and a serialized React tree) to the client. They never ship their code to the browser. ``` Request │ ▼ Server Component Tree (renders on server) │ ├─ Async data fetching (db, fs, fetch) ├─ Heavy dependencies (never in client bundle) └─ Client Component boundaries (marked 'use client') │ ▼ Hydration (client takes over interactive parts only) ``` **Serialization rules — what can cross the server→client boundary:** - Strings, numbers, booleans, null, undefined - Arrays and plain objects of the above - Promises (unwrapped by `use()` on client) - JSX / React elements - **NOT**: functions, class instances, Date objects, Maps, Sets, RegExp (must be serialized or passed differently) --- ## Server Components ```tsx // app/users/page.tsx — Server Component (default, no directive needed) import { db } from '@/lib/db'; import { cache } from 'react'; // cache() deduplicates calls within a single render pass const getUser = cache(async (id: string) => { return db.query.users.findFirst({ where: eq(users.id, id) }); }); // Top-level async component — no useEffect, no loading state needed export default async function UsersPage() { // Fetch in parallel — both start simultaneously const [users, stats] = await Promise.all([ db.query.users.findMany({ limit: 50 }), db.query.stats.findFirst(), ]); return ( <main> <h1>Users ({stats?.total ?? 0})</h1> <UserList users={users} /> </main> ); } ``` ### What You Can Do in Server Components ```tsx // 1. Database queries (Drizzle, Prisma, raw SQL) const posts = await db.select().from(postsTable).where(eq(postsTable.published, true)); // 2. File system access import { readFile } from 'fs/promises'; const content = await readFile('./data/content.md', 'utf8'); // 3. Server-only secrets (never sent to client) const apiData = await fetch('https://api.example.com/data', { headers: { Authorization: `Bearer ${process.env.SECRET_API_KEY}` }, }); // 4. Import heavy libraries without bundle cost import { parse } from 'some-huge-parser'; // 2MB — never in client bundle const result = parse(rawData); // 5. Conditional rendering based on server state/permissions const session = await auth(); if (!session?.user) redirect('/login'); ``` --- ## Client Components ```tsx // components/counter.tsx 'use client'; // marks this module and all its imports as client code import { useState, useEffect } from 'react'; // Anything requiring hooks, browser APIs, or interactivity export function Counter({ initialCount = 0 }: { initialCount?: number }) { const [count, setCount] = useState(initialCount); useEffect(() => { document.title = `Count: ${count}`; }, [count]); return ( <div> <p>{count}</p> <button onClick={() => setCount(c => c + 1)}>Increment</button> </div> ); } ``` ### Passing Server Data to Client Components ```tsx // Server Component (parent) async function ProductPage({ id }: { id: string }) { const product = await db.products.findUnique({ where: { id } }); // Pass serializable data as props return ( <div> <ProductImages images={product.images} /> {/* Server Component */} <AddToCart productId={product.id} // string — serializable price={product.price} // number — serializable // onAdd={addToCart} // ERROR: functions can't cross boundary /> </div> ); } // Pattern: pass Server Component output as children to Client Component async function Layout({ children }: { children: React.ReactNode }) { const nav = await buildNavigation(); // server-only fetch return ( <Shell nav={<ServerNav items={nav} />}> {/* Shell is Client Component */} {children} </Shell> ); } ``` --- ## Server Actions ```tsx // app/actions.ts 'use server'; // all exports are server actions import { revalidatePath, revalidateTag } from 'next/cache'; import { redirect } from 'next/navigation'; import { z } from 'zod'; const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(10), published: z.coerce.boolean().default(false), }); export async function createPost(formData: FormData) { // Validate const parsed = createPostSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return { error: parsed.error.flatten().fieldErrors }; } // Auth check const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); // Persist const post = await db.posts.create({ data: { ...parsed.data, authorId: session.user.id }, }); // Invalidate cache revalidatePath('/posts'); revalidateTag('posts'); // Redirect (throws internally, not caught by try/catch) redirect(`/posts/${post.id}`); } // Progressive enhancement: works without JS, enhanced with JS export async function deletePost(id: string) { await db.posts.delete({ where: { id } }); revalidatePath('/posts'); } ``` ### Form with Server Action ```tsx // app/posts/new/page.tsx import { createPost } from '../actions'; // Server Component — no 'use client' needed export default function NewPostPage() { return ( <form action={createPost}> <input name="title" placeholder="Post title" required /> <textarea name="content" placeholder="Content" required /> <label> <input type="checkbox" name="published" value="true" /> Publish immediately </label> <button type="submit">Create Post</button> </form> ); } ``` ### Server Action with useActionState (React 19) ```tsx 'use client'; import { useActionState } from 'react'; import { createPost } from '../actions'; type ActionState = { error?: Record<string, string[]>; message?: string } | null; export function CreatePostForm() { const [state, action, isPending] = useActionState<ActionState, FormData>( createPost, null ); return ( <form action={action}> <input name="title" aria-invalid={!!state?.error?.title} /> {state?.error?.title && <p role="alert">{state.error.title[0]}</p>} <textarea name="content" /> {state?.error?.content && <p role="alert">{state.error.content[0]}</p>} <button disabled={isPending}> {isPending ? 'Creating...' : 'Create Post'} </button> </form> ); } ``` --- ## Next.js App Router File Conventions ``` app/ ├── layout.tsx # Shared layout (wraps all pages in segment) ├── page.tsx # Route UI (publicly accessible at URL) ├── loading.tsx # Suspense boundary skeleton (automatic) ├── error.tsx # Error boundary fallback (must be 'use client') ├── not-found.tsx # 404 UI (shown by notFound() call) ├── route.ts # API route handler (GET, POST, etc.) ├── template.tsx # Like layout but re-mounts on navigation └── (group)/ # Route group — parentheses = no URL segment └── dashboard/ └── page.tsx # app.com/dashboard ``` ```tsx // app/layout.tsx import { Inter } from 'next/font/google'; import type { Metadata } from 'next'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: { template: '%s | MyApp', default: 'MyApp' }, description: 'My application', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body className={inter.className}>{children}</body> </html> ); } // app/posts/[id]/error.tsx — must be Client Component 'use client'; export default function PostError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return ( <div role="alert"> <h2>Failed to load post</h2> <p>{error.message}</p> <button onClick={reset}>Try again</button> </div> ); } // app/api/users/route.ts — API Route Handler import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const limit = Number(searchParams.get('limit') ?? '20'); const users = await db.users.findMany({ take: limit }); return NextResponse.json(users); } export async function POST(request: NextRequest) { const body = await request.json(); const user = await db.users.create({ data: body }); return NextResponse.json(user, { status: 201 }); } ``` --- ## Caching ```tsx // 1. fetch() cache (Next.js extends native fetch) async function getPost(id: string) { const res = await fetch(`https://api.example.com/posts/${id}`, { next: { revalidate: 3600, // revalidate every 1 hour (ISR) tags: ['posts', `post-${id}`], // tag for on-demand revalidation }, // cache: 'no-store' // disable caching entirely (always fresh) // cache: 'force-cache' // always use cache (default for static) }); return res.json(); } // 2. unstable_cache (for non-fetch data sources like ORMs) import { unstable_cache } from 'next/cache'; const getCachedUsers = unstable_cache( async () => db.users.findMany(), ['users-list'], // cache key { revalidate: 300, tags: ['users'] } // 5 min TTL + tag ); // 3. On-demand revalidation (Server Action or API route) import { revalidatePath, revalidateTag } from 'next/cache'; export async function updatePost(id: string, data: Partial<Post>) { await db.posts.update({ where: { id }, data }); revalidateTag(`post-${id}`); // invalidate specific post cache revalidateTag('posts'); // invalidate all posts list cache revalidatePath('/posts'); // invalidate path-based cache revalidatePath(`/posts/${id}`); } ``` --- ## Streaming with Suspense ```tsx // Wrap slow components in Suspense — page loads instantly, // slow parts stream in progressively import { Suspense } from 'react'; // app/dashboard/page.tsx export default function DashboardPage() { return ( <div className="grid"> {/* Fast — renders immediately */} <WelcomeHeader /> {/* Slow DB queries stream in independently */} <Suspense fallback={<MetricsSkeleton />}> <DashboardMetrics /> {/* async Server Component */} </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> {/* async Server Component */} </Suspense> <Suspense fallback={<ChartSkeleton />}> <RevenueChart /> {/* slow, streams last */} </Suspense> </div> ); } // loading.tsx provides automatic Suspense for the entire segment // app/dashboard/loading.tsx export default function DashboardLoading() { return <DashboardSkeleton />; } ``` --- ## Metadata API ```tsx // Static metadata export const metadata: Metadata = { title: 'My Page', description: 'Page description', openGraph: { title: 'My Page', images: [{ url: '/og-image.png', width: 1200, height: 630 }], }, }; // Dynamic metadata export async function generateMetadata( { params }: { params: { id: string } } ): Promise<Metadata> { const post = await getPost(params.id); if (!post) return { title: 'Post Not Found' }; return { title: post.title, description: post.excerpt, openGraph: { title: post.title, images: [{ url: post.coverImage }], }, alternates: { canonical: `https://mysite.com/posts/${post.slug}`, }, }; } ``` --- ## Patterns to Avoid | Anti-pattern | Problem | Fix | |--------------|---------|-----| | `'use client'` at root layout | Entire app becomes client-side; no RSC benefits | Push `'use client'` to leaf components only | | Waterfall data fetching in Server Components | Each await blocks the next | `Promise.all()` for parallel fetches | | No Suspense boundaries | Entire page waits for slowest component | Wrap each async section in `<Suspense>` | | Server Action without validation | Security risk, bad UX | Always validate with Zod before DB write | | Fetching same data in multiple Server Components | Multiple DB queries for same data | `cache()` wrapper to deduplicate per request | | Passing non-serializable data to Client Components | Runtime error | Only pass strings, numbers, plain objects, JSX | | Large third-party imports in Client Components | Bloated client bundle | Move to Server Component; import only what's needed | | `cookies()` or `headers()` outside Server Components | Runtime error | Only in Server Components, Route Handlers, Server Actions | -
state-management.md 17.7 KB
# State Management Comprehensive reference for React state: Context API, Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form, and URL state. --- ## Decision Matrix | State Type | Scope | Change Freq | Best Tool | |------------|-------|-------------|-----------| | Local UI (toggle, form input) | Component | Any | `useState` / `useReducer` | | Shared, rarely changes (theme, locale, auth) | App-wide | Low | Context API | | Global client state (cart, UI prefs) | App-wide | Medium-High | Zustand | | Atomic/fine-grained state | App-wide | High | Jotai | | Complex flows, large team, time-travel debug | App-wide | Any | Redux Toolkit | | Server data (API responses, cache) | App-wide | External | TanStack Query | | Form state | Component | High | React Hook Form | | URL-driven state (filters, pagination) | Shareable | Medium | `useSearchParams` / nuqs | --- ## Context API ### Basic Pattern ```tsx import { createContext, useContext, useState, useMemo, ReactNode } from 'react'; interface ThemeContextValue { theme: 'light' | 'dark'; toggleTheme: () => void; } // 1. Create context with null default (enforces provider requirement) const ThemeContext = createContext<ThemeContextValue | null>(null); // 2. Custom hook — single usage point, enforces provider export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); return ctx; } // 3. Provider — memoize value to prevent unnecessary consumer re-renders export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<'light' | 'dark'>('light'); // Memoize so object reference only changes when theme changes const value = useMemo( () => ({ theme, toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) }), [theme] ); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } ``` ### Performance: Split Contexts by Update Frequency ```tsx // BAD: single context — every consumer re-renders when ANY value changes const AppContext = createContext({ user, cart, theme, notifications }); // GOOD: separate contexts — consumers only re-render for what they use const UserContext = createContext<User | null>(null); const CartContext = createContext<CartState | null>(null); const ThemeContext = createContext<Theme>('light'); // BAD: context value recreated every render function BadProvider({ children }: { children: ReactNode }) { const [count, setCount] = useState(0); return ( // New object reference every render — all consumers re-render! <MyContext.Provider value={{ count, setCount }}> {children} </MyContext.Provider> ); } // GOOD: memoized value function GoodProvider({ children }: { children: ReactNode }) { const [count, setCount] = useState(0); const value = useMemo(() => ({ count, setCount }), [count]); return <MyContext.Provider value={value}>{children}</MyContext.Provider>; } ``` --- ## Zustand Minimal boilerplate, no providers needed, supports middleware. ### Basic Store ```typescript import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; interface BearState { bears: number; increase: (by?: number) => void; reset: () => void; } const useBearStore = create<BearState>()( devtools( persist( (set) => ({ bears: 0, increase: (by = 1) => set(state => ({ bears: state.bears + by })), reset: () => set({ bears: 0 }), }), { name: 'bear-storage' } // localStorage key ), { name: 'BearStore' } // DevTools display name ) ); // Usage — select only what you need to minimize re-renders function BearCounter() { const bears = useBearStore(state => state.bears); return <p>{bears} bears</p>; } function BearControls() { const increase = useBearStore(state => state.increase); const reset = useBearStore(state => state.reset); return ( <> <button onClick={() => increase()}>+1</button> <button onClick={() => increase(10)}>+10</button> <button onClick={reset}>Reset</button> </> ); } ``` ### Slices Pattern (Large Stores) ```typescript import { create, StateCreator } from 'zustand'; // Slice 1: auth interface AuthSlice { user: User | null; login: (user: User) => void; logout: () => void; } const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = set => ({ user: null, login: (user) => set({ user }), logout: () => set({ user: null }), }); // Slice 2: cart interface CartSlice { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; } const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = set => ({ items: [], addItem: (item) => set(state => ({ items: [...state.items, item] })), removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })), }); // Combined store const useStore = create<AuthSlice & CartSlice>()((...args) => ({ ...createAuthSlice(...args), ...createCartSlice(...args), })); // Focused selectors — each component subscribes to only its slice export const useUser = () => useStore(state => state.user); export const useCart = () => useStore(state => state.items); export const useCartActions = () => useStore(state => ({ addItem: state.addItem, removeItem: state.removeItem })); ``` --- ## Jotai Atomic state model — compose fine-grained atoms instead of one store. ```typescript import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { atomWithStorage, atomWithReset } from 'jotai/utils'; // Primitive atoms const countAtom = atom(0); const nameAtom = atom(''); // Derived (read-only) atom const doubledAtom = atom(get => get(countAtom) * 2); // Write-only atom const incrementAtom = atom(null, (get, set) => { set(countAtom, get(countAtom) + 1); }); // Async atom — integrates with Suspense const userAtom = atom(async () => { const res = await fetch('/api/me'); return res.json() as Promise<User>; }); // Persistent atom (localStorage) const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light'); // Resettable atom const filterAtom = atomWithReset({ search: '', category: 'all' }); // Usage function Counter() { const [count, setCount] = useAtom(countAtom); const doubled = useAtomValue(doubledAtom); const increment = useSetAtom(incrementAtom); return ( <div> <p>Count: {count}, Doubled: {doubled}</p> <button onClick={increment}>Increment</button> <button onClick={() => setCount(0)}>Reset</button> </div> ); } ``` --- ## Redux Toolkit Best for large teams, complex state machines, and when time-travel debugging matters. ### Slice + Thunk ```typescript import { createSlice, createAsyncThunk, PayloadAction, createEntityAdapter, } from '@reduxjs/toolkit'; import type { RootState, AppDispatch } from './store'; // Entity adapter for normalized CRUD const usersAdapter = createEntityAdapter<User>(); // Async thunk for data fetching export const fetchUsers = createAsyncThunk( 'users/fetchAll', async (_, { rejectWithValue }) => { try { const res = await fetch('/api/users'); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as User[]; } catch (err) { return rejectWithValue((err as Error).message); } } ); // Slice const usersSlice = createSlice({ name: 'users', initialState: usersAdapter.getInitialState({ status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed', error: null as string | null, }), reducers: { userAdded: usersAdapter.addOne, userUpdated: usersAdapter.updateOne, userRemoved: usersAdapter.removeOne, }, extraReducers: builder => { builder .addCase(fetchUsers.pending, state => { state.status = 'loading'; }) .addCase(fetchUsers.fulfilled, (state, action) => { state.status = 'succeeded'; usersAdapter.setAll(state, action.payload); }) .addCase(fetchUsers.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload as string; }); }, }); // Selectors from adapter export const { selectAll: selectAllUsers, selectById: selectUserById } = usersAdapter.getSelectors((state: RootState) => state.users); // Custom selectors export const selectUsersStatus = (state: RootState) => state.users.status; export const { userAdded, userUpdated, userRemoved } = usersSlice.actions; export default usersSlice.reducer; ``` ### RTK Query (preferred over sagas/thunks for data fetching) ```typescript import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; export const apiSlice = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: '/api', prepareHeaders: (headers, { getState }) => { const token = (getState() as RootState).auth.token; if (token) headers.set('Authorization', `Bearer ${token}`); return headers; }, }), tagTypes: ['User', 'Post'], endpoints: builder => ({ getUsers: builder.query<User[], void>({ query: () => '/users', providesTags: ['User'], }), getUserById: builder.query<User, string>({ query: id => `/users/${id}`, providesTags: (result, error, id) => [{ type: 'User', id }], }), createUser: builder.mutation<User, Partial<User>>({ query: body => ({ url: '/users', method: 'POST', body }), invalidatesTags: ['User'], }), updateUser: builder.mutation<User, Pick<User, 'id'> & Partial<User>>({ query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: 'PATCH', body: patch }), invalidatesTags: (result, error, { id }) => [{ type: 'User', id }], }), }), }); export const { useGetUsersQuery, useGetUserByIdQuery, useCreateUserMutation, useUpdateUserMutation, } = apiSlice; // Usage function UserList() { const { data: users = [], isLoading, isError } = useGetUsersQuery(); const [createUser, { isLoading: isCreating }] = useCreateUserMutation(); if (isLoading) return <Spinner />; if (isError) return <Error />; return ( <> {users.map(u => <UserCard key={u.id} user={u} />)} <button onClick={() => createUser({ name: 'New User' })} disabled={isCreating}> Add User </button> </> ); } ``` --- ## TanStack Query (React Query) The standard for server state. Handles caching, background refetch, stale-while-revalidate. ```typescript import { useQuery, useMutation, useQueryClient, useInfiniteQuery, QueryClient, QueryClientProvider, } from '@tanstack/react-query'; // Setup const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // data fresh for 1 minute retry: 3, }, }, }); function App() { return ( <QueryClientProvider client={queryClient}> <Router /> </QueryClientProvider> ); } // Basic query function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error, refetch } = useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId), enabled: !!userId, // only run when userId is truthy staleTime: 5 * 60 * 1000, // override: 5 minutes }); if (isLoading) return <Skeleton />; if (error) return <Error onRetry={refetch} />; return <div>{user?.name}</div>; } // Mutation with optimistic update function DeleteButton({ userId }: { userId: string }) { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (id: string) => deleteUser(id), // Optimistic update onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: ['users'] }); const previousUsers = queryClient.getQueryData<User[]>(['users']); queryClient.setQueryData<User[]>(['users'], old => old?.filter(u => u.id !== id) ?? [] ); return { previousUsers }; // context for rollback }, // Rollback on error onError: (err, id, context) => { if (context?.previousUsers) { queryClient.setQueryData(['users'], context.previousUsers); } }, // Always invalidate after settle onSettled: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, }); return ( <button onClick={() => mutation.mutate(userId)} disabled={mutation.isPending} > {mutation.isPending ? 'Deleting...' : 'Delete'} </button> ); } // Infinite query (pagination / infinite scroll) function PostFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }), initialPageParam: undefined as string | undefined, getNextPageParam: lastPage => lastPage.nextCursor, }); const posts = data?.pages.flatMap(page => page.posts) ?? []; return ( <> {posts.map(post => <PostCard key={post.id} post={post} />)} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> </> ); } // Prefetch on hover (instant navigation feel) function PostLink({ postId }: { postId: string }) { const queryClient = useQueryClient(); return ( <a href={`/posts/${postId}`} onMouseEnter={() => queryClient.prefetchQuery({ queryKey: ['posts', postId], queryFn: () => fetchPost(postId), })} > Read More </a> ); } ``` --- ## React Hook Form ```typescript import { useForm, Controller, SubmitHandler } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // 1. Define schema with Zod const profileSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email address'), age: z.coerce.number().int().min(18, 'Must be at least 18').max(120), role: z.enum(['admin', 'user', 'moderator']), bio: z.string().max(500).optional(), }); type ProfileForm = z.infer<typeof profileSchema>; // 2. Form component function ProfileForm({ onSave }: { onSave: (data: ProfileForm) => Promise<void> }) { const { register, handleSubmit, control, formState: { errors, isSubmitting, isDirty }, reset, } = useForm<ProfileForm>({ resolver: zodResolver(profileSchema), defaultValues: { name: '', email: '', age: 18, role: 'user' }, }); const onSubmit: SubmitHandler<ProfileForm> = async data => { await onSave(data); reset(); // reset to defaultValues after success }; return ( <form onSubmit={handleSubmit(onSubmit)} noValidate> <div> <label htmlFor="name">Name</label> <input id="name" {...register('name')} aria-describedby={errors.name ? 'name-error' : undefined} aria-invalid={!!errors.name} /> {errors.name && ( <span id="name-error" role="alert">{errors.name.message}</span> )} </div> {/* Controller for third-party input components */} <Controller name="role" control={control} render={({ field }) => ( <Select value={field.value} onChange={field.onChange} options={['admin', 'user', 'moderator']} /> )} /> <button type="submit" disabled={isSubmitting || !isDirty}> {isSubmitting ? 'Saving...' : 'Save Profile'} </button> </form> ); } ``` --- ## URL State ```typescript import { useSearchParams } from 'react-router-dom'; // Built-in useSearchParams (React Router v6) function ProductFilters() { const [searchParams, setSearchParams] = useSearchParams(); const category = searchParams.get('category') ?? 'all'; const page = Number(searchParams.get('page') ?? '1'); const setFilter = (key: string, value: string) => { setSearchParams(prev => { prev.set(key, value); if (key !== 'page') prev.set('page', '1'); // reset page on filter change return prev; }); }; return ( <div> <select value={category} onChange={e => setFilter('category', e.target.value)} > <option value="all">All</option> <option value="shoes">Shoes</option> </select> <Pagination currentPage={page} onPageChange={p => setFilter('page', String(p))} /> </div> ); } // nuqs — type-safe URL state (Next.js or any framework) // npm install nuqs import { useQueryState, parseAsInteger, parseAsString } from 'nuqs'; function FilterPage() { const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); const [search, setSearch] = useQueryState('q', parseAsString.withDefault('')); return ( <div> <input value={search} onChange={e => setSearch(e.target.value)} /> <button onClick={() => setPage(p => p + 1)}>Next page ({page})</button> </div> ); } ``` --- ## Anti-patterns | Anti-pattern | Problem | Fix | |--------------|---------|-----| | `useState` + `useEffect` for server data | Manual loading/error/cache management, stale data | TanStack Query | | Single massive Context for all global state | Every consumer re-renders on any change | Split contexts by update frequency | | Putting functions in Context value without `useMemo` | New object reference every render | `useMemo` the value | | Zustand selectors that return objects | New object reference every call triggers re-render | Select primitives; or use `shallow` equality: `useStore(state => state.items, shallow)` | | `useEffect` to sync two pieces of state | Double render, complexity | Derive state during render or use `useReducer` | | Redux for everything including server data | Over-normalized, async complexity | RTK Query or TanStack Query for server state | | No staleTime in TanStack Query | Constant background refetches on every mount | Set appropriate `staleTime` per query | -
testing.md 14.6 KB
# Testing React Testing Library, user-event, MSW, Vitest setup, hook testing, and accessibility testing. --- ## Philosophy Test behavior, not implementation. Tests should resemble how users interact with your app. - Query by what the user sees (role, label, text) — not by class names or IDs - Interact the way users do (click, type, submit) — not by calling component methods - Assert what the user sees as the outcome — not component state --- ## Vitest + RTL Setup ```bash npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom ``` ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, // no import { describe, it, expect } needed setupFiles: ['./src/test/setup.ts'], coverage: { provider: 'v8', reporter: ['text', 'lcov'], exclude: ['**/*.stories.tsx', '**/index.ts'], }, }, }); ``` ```typescript // src/test/setup.ts import '@testing-library/jest-dom'; // extends expect with DOM matchers import { cleanup } from '@testing-library/react'; import { afterEach, beforeAll, afterAll } from 'vitest'; import { server } from './mocks/server'; // RTL cleanup after each test afterEach(() => cleanup()); // MSW lifecycle beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); ``` --- ## Rendering and Queries ### Query Priority ``` 1. getByRole — semantic HTML, accessible name 2. getByLabelText — form labels 3. getByPlaceholderText — input placeholders (prefer label) 4. getByText — visible text content 5. getByDisplayValue — current input value 6. getByAltText — img alt text 7. getByTitle — title attribute 8. getByTestId — last resort: data-testid="..." ``` ### getBy vs queryBy vs findBy | Variant | Returns | Throws | Async | |---------|---------|--------|-------| | `getBy*` | Element | If not found | No | | `queryBy*` | Element or null | No | No | | `findBy*` | Promise<Element> | If timeout | Yes | | `getAllBy*` | Element[] | If none found | No | | `queryAllBy*` | Element[] | No | No | | `findAllBy*` | Promise<Element[]> | If timeout | Yes | ```tsx import { render, screen } from '@testing-library/react'; test('renders user profile', () => { render(<UserProfile user={{ name: 'Alice', role: 'admin' }} />); // Role-based (preferred) — uses ARIA roles expect(screen.getByRole('heading', { name: 'Alice' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /edit profile/i })).toBeEnabled(); // Text content expect(screen.getByText('admin')).toBeInTheDocument(); // For content that should NOT be present expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); }); ``` --- ## User Interactions Always use `@testing-library/user-event` over `fireEvent` — it simulates real browser events including pointer events, focus, keyboard navigation. ```tsx import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; describe('LoginForm', () => { // Create user instance once per test — manages pointer state const user = userEvent.setup(); test('submits with valid credentials', async () => { const onLogin = vi.fn(); render(<LoginForm onLogin={onLogin} />); // Type into inputs (fires focus, input, change, keydown/up events) await user.type(screen.getByLabelText(/email/i), 'alice@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); // Click submit await user.click(screen.getByRole('button', { name: /sign in/i })); expect(onLogin).toHaveBeenCalledWith({ email: 'alice@example.com', password: 'password123', }); }); test('shows validation error for empty email', async () => { render(<LoginForm onLogin={vi.fn()} />); // Tab to trigger blur validation without typing await user.click(screen.getByLabelText(/email/i)); await user.tab(); expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i); }); test('disables submit while loading', async () => { render(<LoginForm onLogin={() => new Promise(() => {})} />); // never resolves await user.type(screen.getByLabelText(/email/i), 'alice@example.com'); await user.type(screen.getByLabelText(/password/i), 'pass'); await user.click(screen.getByRole('button', { name: /sign in/i })); expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled(); }); }); ``` ### Select, Keyboard, Upload ```tsx // Select dropdown await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'Canada'); expect(screen.getByRole('option', { name: 'Canada' })).toBeSelected(); // Keyboard shortcuts await user.keyboard('{Escape}'); // press Escape await user.keyboard('{Control>}k{/Control}'); // Ctrl+K // File upload const file = new File(['content'], 'test.pdf', { type: 'application/pdf' }); await user.upload(screen.getByLabelText(/upload/i), file); // Clear an input await user.clear(screen.getByRole('textbox', { name: /search/i })); ``` --- ## Async Testing ```tsx import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; test('loads and displays users', async () => { render(<UserList />); // Assert loading state expect(screen.getByRole('status')).toHaveTextContent(/loading/i); // Wait for async operation to complete await waitFor(() => { expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); // Alternatively: wait for element to disappear await waitForElementToBeRemoved(() => screen.queryByRole('status')); // Assert loaded state expect(screen.getByRole('list')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(3); }); // findBy* — combines waitFor + getBy test('shows error on failed load', async () => { server.use( http.get('/api/users', () => HttpResponse.error()) ); render(<UserList />); // findBy waits up to 1000ms by default const error = await screen.findByRole('alert'); expect(error).toHaveTextContent(/failed to load/i); }); ``` --- ## Custom Render with Providers ```tsx // src/test/utils.tsx import { render, RenderOptions } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; import { ReactNode } from 'react'; interface WrapperOptions { initialRoute?: string; } function createWrapper({ initialRoute = '/' }: WrapperOptions = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, // no retries in tests mutations: { retry: false }, }, }); return function Wrapper({ children }: { children: ReactNode }) { return ( <QueryClientProvider client={queryClient}> <MemoryRouter initialEntries={[initialRoute]}> {children} </MemoryRouter> </QueryClientProvider> ); }; } // Custom render — drop-in replacement for RTL's render function customRender( ui: React.ReactElement, options: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {} ) { const { initialRoute, ...renderOptions } = options; return render(ui, { wrapper: createWrapper({ initialRoute }), ...renderOptions, }); } // Re-export everything from RTL so tests only need to import from here export * from '@testing-library/react'; export { customRender as render }; ``` ```tsx // Usage in tests — exact same API as RTL import { render, screen } from '../test/utils'; test('navigates to profile', async () => { const user = userEvent.setup(); render(<App />, { initialRoute: '/dashboard' }); await user.click(screen.getByRole('link', { name: /profile/i })); expect(screen.getByRole('heading', { name: /your profile/i })).toBeInTheDocument(); }); ``` --- ## MSW (Mock Service Worker) MSW intercepts real network requests — no mocking of fetch/axios needed. ```typescript // src/test/mocks/handlers.ts import { http, HttpResponse } from 'msw'; const mockUsers: User[] = [ { id: '1', name: 'Alice', email: 'alice@example.com' }, { id: '2', name: 'Bob', email: 'bob@example.com' }, ]; export const handlers = [ // GET /api/users http.get('/api/users', () => { return HttpResponse.json(mockUsers); }), // GET /api/users/:id http.get('/api/users/:id', ({ params }) => { const user = mockUsers.find(u => u.id === params.id); if (!user) return new HttpResponse(null, { status: 404 }); return HttpResponse.json(user); }), // POST /api/users http.post('/api/users', async ({ request }) => { const body = await request.json() as Partial<User>; const newUser = { id: crypto.randomUUID(), ...body } as User; return HttpResponse.json(newUser, { status: 201 }); }), // DELETE /api/users/:id http.delete('/api/users/:id', ({ params }) => { return new HttpResponse(null, { status: 204 }); }), ]; ``` ```typescript // src/test/mocks/server.ts import { setupServer } from 'msw/node'; import { handlers } from './handlers'; export const server = setupServer(...handlers); ``` ```tsx // Override handlers in specific tests import { server } from '../test/mocks/server'; import { http, HttpResponse } from 'msw'; test('shows error when API fails', async () => { // Override default handler for this test only server.use( http.get('/api/users', () => { return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }); }) ); render(<UserList />); await screen.findByRole('alert'); expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i); }); ``` --- ## Hook Testing ```tsx import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; test('useCounter increments correctly', () => { const { result } = renderHook(() => useCounter(0)); expect(result.current.count).toBe(0); act(() => result.current.increment()); expect(result.current.count).toBe(1); act(() => result.current.incrementBy(5)); expect(result.current.count).toBe(6); act(() => result.current.reset()); expect(result.current.count).toBe(0); }); // Test hooks that use context test('useTheme reads from ThemeProvider', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( <ThemeProvider initialTheme="dark">{children}</ThemeProvider> ); const { result } = renderHook(() => useTheme(), { wrapper }); expect(result.current.theme).toBe('dark'); act(() => result.current.toggleTheme()); expect(result.current.theme).toBe('light'); }); // Test async hooks test('useFetch returns data', async () => { const { result } = renderHook(() => useFetch<User[]>('/api/users')); expect(result.current.isLoading).toBe(true); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toHaveLength(2); expect(result.current.error).toBeNull(); }); ``` --- ## Component Testing Patterns ### Modal ```tsx test('modal opens and closes', async () => { const user = userEvent.setup(); render(<DeleteConfirmation onDelete={vi.fn()} />); // Modal should not be in DOM initially expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /delete/i })); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toHaveAccessibleName(/confirm deletion/i); await user.click(screen.getByRole('button', { name: /cancel/i })); await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); }); ``` ### Form Validation ```tsx test('validates required fields on submit', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<ContactForm onSubmit={onSubmit} />); // Submit empty form await user.click(screen.getByRole('button', { name: /submit/i })); // Errors appear expect(screen.getByText(/name is required/i)).toBeInTheDocument(); expect(screen.getByText(/email is required/i)).toBeInTheDocument(); // Form was not submitted expect(onSubmit).not.toHaveBeenCalled(); }); ``` --- ## Accessibility Testing ```tsx import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations()); test('has no accessibility violations', async () => { const { container } = render(<LoginForm onLogin={vi.fn()} />); const results = await axe(container); expect(results).toHaveNoViolations(); }); // Test keyboard navigation test('modal is keyboard accessible', async () => { const user = userEvent.setup(); render(<Modal isOpen onClose={vi.fn()} title="Confirm">Content</Modal>); const dialog = screen.getByRole('dialog'); // Dialog should have correct ARIA attributes expect(dialog).toHaveAttribute('aria-modal', 'true'); expect(dialog).toHaveAccessibleName('Confirm'); // Escape closes modal await user.keyboard('{Escape}'); // ... assert closed }); // Test screen reader text test('icon button has accessible name', () => { render(<button aria-label="Close menu"><XIcon /></button>); expect(screen.getByRole('button', { name: /close menu/i })).toBeInTheDocument(); }); ``` --- ## Snapshot Testing Use sparingly — for stable UI components where visual regression is more important than behavior. ```tsx // PREFER behavioral tests over snapshots // Use snapshots only for: // - Stable design system components (Button, Badge, Avatar) // - Complex SVG/icon output // - Error messages with specific formatting import { render } from '@testing-library/react'; test('Badge renders correctly', () => { const { container } = render(<Badge variant="success" count={5} />); expect(container.firstChild).toMatchSnapshot(); }); // Update snapshots when intentional changes are made: // vitest --update-snapshots ``` --- ## Anti-patterns | Anti-pattern | Problem | Fix | |--------------|---------|-----| | Query by CSS class or id | Brittle, implementation detail | Query by role, label, or text | | `fireEvent` instead of `userEvent` | Doesn't fire real browser events | Use `@testing-library/user-event` | | Testing internal state | Tests break on refactor | Test rendered output and behavior | | Mocking React components | Hides integration bugs | Test with real components; mock network instead | | No async awaiting | Tests pass before assertions run | Always `await` user interactions and async queries | | `data-testid` as first choice | Couples tests to implementation | Last resort after semantic queries fail | | Test per implementation detail | Brittle test suite | Test per user story / behavior | | No error case tests | Only happy path covered | Test loading, error, empty, and edge states |
-
-
scripts
-
.gitkeep 0 B · in bundle
-
check-react-facts.py 11 KB
#!/usr/bin/env python3 """Staleness verifier for react-ops: the React 19 facts the skill encodes must stay real and named in the prose. react-ops centers on React 19 (use(), Actions, useActionState, useFormStatus, useOptimistic, React Compiler) and names an ecosystem stack (Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form, Zod). That is exactly the fact that drifts silently (SKILL-RESOURCE-PROTOCOL.md §7): a package moves a major version upstream, or the prose stops mentioning a package the catalog lists, and nobody notices for months. Two modes guard it: --offline (default, safe for PR CI): structural consistency, no network. * assets/react-facts.json parses and every package + version gate is named somewhere in the skill prose (SKILL.md / references/*.md) — the catalog can't drift from the docs * SKILL.md still carries a dated "as of <year>" currency note --live (scheduled freshness.yml, never a PR gate): query the npm registry for each package's latest dist-tag; flag DRIFT when the live major is newer than the documented major (the skill is now behind), or when a package is gone (404). Transient registry failure is UNAVAILABLE (exit 7), never a failure. Usage: check-react-facts.py [--offline | --live] [--facts FILE] [--skill DIR] [--json] [--timeout S] Input: argv flags only (no stdin). Output: stdout = findings (plain rows, or a --json envelope). Data only. Stderr: the verdict line, notices, errors. Exit: 0 ok, 2 usage, 3 facts/skill missing, 4 facts unparseable, 7 npm registry unreachable (live, advisory — never a real failure), 10 drift (offline: uncited/undocumented/missing note; live: major ahead or gone) Examples: check-react-facts.py --offline # PR CI: catalog ⇆ prose consistency check-react-facts.py --live # weekly: is any documented major behind npm? check-react-facts.py --offline --json | jq '.data[]' """ from __future__ import annotations import argparse import json import os import re import sys import urllib.error import urllib.parse import urllib.request from pathlib import Path EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_UNPARSEABLE = 4 EX_UNAVAILABLE = 7 EX_DRIFT = 10 SCHEMA = "claude-mods.react-ops.facts/v1" HERE = Path(__file__).resolve().parent DEFAULT_FACTS = HERE.parent / "assets" / "react-facts.json" DEFAULT_SKILL = HERE.parent REGISTRY = "https://registry.npmjs.org" CURRENCY_RE = re.compile(r"as of 20\d\d") class Term: """Minimal ANSI helper. Honors FORCE_COLOR / NO_COLOR / TERM_ASCII and the bound stream's TTY + encoding so piped data stays plain ASCII.""" _C = {"green": "\033[32m", "red": "\033[31m", "dim": "\033[2m", "off": "\033[0m"} def __init__(self, stream=sys.stderr): enc = (getattr(stream, "encoding", "") or "").lower() self.ascii = os.environ.get("TERM_ASCII") == "1" or "utf" not in enc if os.environ.get("FORCE_COLOR"): self.color = True elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb" or not getattr(stream, "isatty", lambda: False)()): self.color = False else: self.color = True def c(self, name, text): return f"{self._C.get(name, '')}{text}{self._C['off']}" if self.color else text def mark(self, ok): g = ("+" if self.ascii else "✓") if ok else ("x" if self.ascii else "✗") return self.c("green" if ok else "red", g) def load_facts(path: Path) -> dict: if not path.is_file(): print(f"error: facts catalog not found: {path}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) try: data = json.loads(path.read_text(encoding="utf-8")) if data.get("schema") != SCHEMA: raise ValueError(f"schema {data.get('schema')!r} != {SCHEMA!r}") if not isinstance(data.get("packages"), dict) or not data["packages"]: raise ValueError("'packages' must be a non-empty object") for name, info in data["packages"].items(): if not isinstance(info, dict) or "documented_major" not in info: raise ValueError(f"package {name!r} missing documented_major") if not isinstance(info.get("prose"), list) or not info["prose"]: raise ValueError(f"package {name!r} missing prose tokens") return data except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: print(f"error: could not parse facts {path}: {exc}", file=sys.stderr) raise SystemExit(EX_UNPARSEABLE) def read_corpus(skill_dir: Path) -> tuple[str, str]: """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md.""" doc = skill_dir / "SKILL.md" if not doc.is_file(): print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr) raise SystemExit(EX_NOTFOUND) skill_md = doc.read_text(encoding="utf-8", errors="replace") parts = [skill_md] for ref in sorted((skill_dir / "references").glob("*.md")): parts.append(ref.read_text(encoding="utf-8", errors="replace")) return skill_md, "\n".join(parts) def check_offline(facts: dict, skill_dir: Path) -> list[dict]: skill_md, corpus = read_corpus(skill_dir) findings: list[dict] = [] for name, info in facts["packages"].items(): for token in info["prose"]: if token not in corpus: findings.append({"package": name, "issue": f"prose token {token!r} not named in skill"}) for key, token in facts.get("version_gates", {}).items(): if key == "_comment": continue if str(token) not in corpus: findings.append({"package": "(gate)", "issue": f"version gate {key}={token!r} not stated in skill prose"}) if not CURRENCY_RE.search(skill_md): findings.append({"package": "(SKILL.md)", "issue": "no dated 'as of <year>' currency note"}) return findings def npm_latest(name: str, timeout: float) -> tuple[str, object]: """Return (resolved|notfound|unavailable, version-string-or-status).""" url = f"{REGISTRY}/{urllib.parse.quote(name, safe='')}/latest" req = urllib.request.Request(url, method="GET", headers={"User-Agent": "claude-mods-react-ops-check/1", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: manifest = json.loads(resp.read().decode("utf-8")) return ("resolved", manifest.get("version", "")) except urllib.error.HTTPError as exc: if exc.code in (404, 410): return ("notfound", exc.code) return ("unavailable", exc.code) except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: return ("unavailable", str(getattr(exc, "reason", exc))) def major_of(version: str) -> int | None: m = re.match(r"\d+", version.strip()) return int(m.group(0)) if m else None def check_live(facts: dict, timeout: float) -> tuple[list[dict], list[dict]]: drift: list[dict] = [] unreachable: list[dict] = [] for name, info in facts["packages"].items(): documented = info["documented_major"] status, info2 = npm_latest(name, timeout) if status == "notfound": drift.append({"package": name, "issue": "no longer resolves on npm (404) — renamed/removed"}) elif status == "unavailable": unreachable.append({"package": name, "issue": f"registry unreachable: {info2}"}) else: live = major_of(str(info2)) if live is None: unreachable.append({"package": name, "issue": f"could not parse version {info2!r}"}) elif live > documented: drift.append({"package": name, "issue": f"live major {live} ({info2}) ahead of documented major {documented}"}) return drift, unreachable def main(argv: list[str]) -> int: p = argparse.ArgumentParser( prog="check-react-facts.py", description="Verify react-ops' React 19 facts stay named (offline) and current on npm (live).", ) mode = p.add_mutually_exclusive_group() mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)") mode.add_argument("--live", action="store_true", help="check each package's npm major vs documented") p.add_argument("--facts", default=str(DEFAULT_FACTS), help="facts catalog JSON") p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)") p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)") p.add_argument("--json", action="store_true", help="emit a JSON envelope") try: args = p.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) facts = load_facts(Path(args.facts)) live = args.live and not args.offline t = Term(sys.stderr) if live: drift, unreachable = check_live(facts, args.timeout) findings = drift + unreachable if args.json: print(json.dumps({ "data": findings, "meta": {"mode": "live", "packages_checked": len(facts["packages"]), "drift": len(drift), "unreachable": len(unreachable), "registry": REGISTRY, "schema": SCHEMA}, }, indent=2)) else: for f in findings: kind = "DRIFT" if f in drift else "UNREACH" print(f"{kind} {f['package']}: {f['issue']}") if drift: print(f"{t.mark(False)} react-facts/live: {len(drift)} package(s) drifted " f"{t.c('dim', '(' + REGISTRY + ')')}", file=sys.stderr) return EX_DRIFT if unreachable: print(f"{t.mark(False)} react-facts/live: npm unreachable for " f"{len(unreachable)}/{len(facts['packages'])} {t.c('dim', '(advisory - retry next run)')}", file=sys.stderr) return EX_UNAVAILABLE print(f"{t.mark(True)} react-facts/live: all {len(facts['packages'])} package(s) " f"at or below documented major", file=sys.stderr) return EX_OK # offline (default) findings = check_offline(facts, Path(args.skill)) if args.json: print(json.dumps({ "data": findings, "meta": {"mode": "offline", "packages_checked": len(facts["packages"]), "drift": len(findings), "consistent": not findings, "schema": SCHEMA}, }, indent=2)) else: for f in findings: print(f"DRIFT {f['package']}: {f['issue']}") ok = not findings print(f"{t.mark(ok)} react-facts/offline: {len(facts['packages'])} package(s) + " f"{sum(1 for k in facts.get('version_gates', {}) if k != '_comment')} gate(s) checked, " f"{len(findings)} inconsistency {t.c('dim', '(catalog vs skill prose)')}", file=sys.stderr) return EX_DRIFT if findings else EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
-
-
tests
-
run.sh 3.5 KB
#!/usr/bin/env bash # Offline self-test for the react-ops skill — structure, frontmatter, and the # staleness-verifier contract (SKILL-RESOURCE-PROTOCOL.md §7, §10). # # Offline-deterministic (no network, no React install). Resolves paths relative # to itself so it works in the repo and once installed to ~/.claude/skills/. # # Usage: bash tests/run.sh # Input: none (self-contained; no network) # Output: TAP-ish progress on stderr; final PASS/FAIL line. # Exit: 0 all pass, 1 any failure set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" DOC="$SKILL/SKILL.md" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1" >&2; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } # Resolve a *working* python (python3, else python). The bare `command -v` is # not enough on Windows, where `python3` is a Microsoft Store stub that exits # nonzero. Skip the whole verifier block if none works. PY="" for c in python3 python py; do if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PY="$c"; break; fi done echo "=== react-ops self-test ===" >&2 # ── SKILL.md frontmatter ─────────────────────────────────────────────────── [[ -f "$DOC" ]] && ok "SKILL.md present" || { no "SKILL.md missing"; echo "=== $PASS passed, $FAIL failed ===" >&2; exit 1; } doc="$(cat "$DOC")" case "$doc" in *"name: react-ops"*) ok "frontmatter declares name: react-ops";; *) no "frontmatter name != react-ops";; esac case "$doc" in *"license: MIT"*) ok "frontmatter declares license: MIT";; *) no "missing license: MIT";; esac case "$doc" in *"as of 20"*) ok "currency note carries a year";; *) no "no dated 'as of <year>' currency note";; esac # ── resources present + cited ────────────────────────────────────────────── for res in assets/react-facts.json scripts/check-react-facts.py; do [[ -f "$SKILL/$res" ]] && ok "resource present: $res" || no "missing resource: $res" done case "$doc" in *"scripts/check-react-facts.py"*) ok "verifier cited from SKILL.md";; *) no "verifier uncited";; esac # ── staleness verifier: offline contract (§7) ─────────────────────────────── if [[ -n "$PY" ]]; then V="$SKILL/scripts/check-react-facts.py" F="$SKILL/assets/react-facts.json" ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$? [[ "$got" == "$want" ]] && ok "$lbl (exit $got)" || no "$lbl (want $want got $got)"; } TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT ec 0 "py_compile" "$PY" -m py_compile "$V" ec 0 "--help" "$PY" "$V" --help ec 0 "--offline consistent" "$PY" "$V" --offline ec 2 "bad flag -> 2" "$PY" "$V" --bogus ec 2 "conflicting modes -> 2" "$PY" "$V" --offline --live jout="$("$PY" "$V" --offline --json 2>/dev/null)" case "$jout" in *"claude-mods.react-ops.facts/v1"*) ok "--json envelope schema";; *) no "--json envelope schema missing";; esac ec 3 "missing facts -> 3" "$PY" "$V" --offline --facts "$TMP/nope.json" printf '{"schema":"claude-mods.react-ops.facts/v1","packages":{"zzz":{"documented_major":1,"prose":["zzznotreal"]}}}' > "$TMP/drift.json" ec 10 "uncited package -> 10" "$PY" "$V" --offline --facts "$TMP/drift.json" else no "no working python to exercise the verifier" fi echo "=== $PASS passed, $FAIL failed ===" >&2 [[ "$FAIL" -eq 0 ]] || exit 1
-
-
SKILL.md 12.9 KB
--- name: react-ops description: "React development patterns, hooks, state management, Server Components, and performance optimization. Use for: react, hooks, useState, useEffect, jsx, tsx, server components, RSC, zustand, react query, component patterns, react testing library, error boundary, suspense, react 19." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: nextjs-ops, typescript-ops, testing-ops, tailwind-ops, javascript-ops --- # React Operations Comprehensive React skill covering hooks, component architecture, state management, Server Components, and performance optimization. > React 19 ecosystem facts verified as of 2026-07. ## Hook Selection Decision Tree ``` What problem are you solving? │ ├─ Storing UI state that triggers re-renders │ ├─ Simple value (string, number, boolean) │ │ └─ useState │ ├─ Complex state with multiple sub-values and logic │ │ └─ useReducer (actions + reducer = predictable transitions) │ └─ Derived from existing state │ └─ Calculate inline or useMemo — not useState │ ├─ Referencing a value WITHOUT triggering re-render │ ├─ DOM element reference │ │ └─ useRef<HTMLElement>(null) + ref={ref} │ └─ Mutable value (timer ID, previous value, counter) │ └─ useRef (mutate ref.current directly) │ ├─ Running a side effect │ ├─ After every render (or specific deps) │ │ ├─ Needs cleanup (subscription, timer, abort) │ │ │ └─ useEffect with return cleanup function │ │ └─ No cleanup (logging, analytics) │ │ └─ useEffect with empty or dep array │ ├─ Before browser paint (DOM mutation, animation) │ │ └─ useLayoutEffect │ └─ Triggered by user action (not render) │ └─ Call it directly in the event handler — not useEffect │ ├─ Caching an expensive computation │ └─ useMemo(() => expensiveCalc(a, b), [a, b]) │ ├─ Stable callback reference for child props / event handlers │ └─ useCallback(() => doThing(dep), [dep]) │ ├─ Reading shared context value │ └─ useContext(MyContext) │ ├─ Generating stable unique ID (forms, aria) │ └─ useId() │ ├─ Syncing external store (Redux, Zustand internals) │ └─ useSyncExternalStore(subscribe, getSnapshot) │ └─ React 19+ ├─ Await a promise or read context │ └─ use(promise | context) ├─ Form submit state (pending, data, action) │ └─ useFormStatus / useActionState └─ Optimistic UI before server response └─ useOptimistic(state, updateFn) ``` ## Component Pattern Decision Tree ``` What's your composition challenge? │ ├─ Group of related components sharing implicit state │ (Tabs, Accordion, Select, Menu) │ └─ Compound Components with Context │ Parent provides state via Context │ Children consume via useContext │ ├─ Consumer needs to control rendering output │ └─ Render Props: children(props) or render={fn} │ Good for: headless UI, flexible layouts │ ├─ Apply cross-cutting concerns (auth, logging, theming) │ to multiple components │ └─ Higher-Order Components (HOC) │ Wrap with withAuth(Component) or withLogging(Component) │ Prefer custom hooks for pure logic │ ├─ Encapsulate reusable stateful logic │ └─ Custom Hook — always prefer over HOC when possible │ Composable, testable, no wrapper hell │ ├─ Need imperative control from parent (focus, scroll, reset) │ └─ forwardRef + useImperativeHandle │ ├─ Render content outside DOM hierarchy (modal, tooltip, toast) │ └─ Portal: createPortal(content, document.body) │ ├─ Accept arbitrary children/slots without prop drilling │ └─ Slot pattern via children, or named props (header, footer) │ └─ Polymorphic rendering (button that renders as <a> or div) └─ as prop pattern with TypeScript generics ``` ## State Management Decision Tree ``` Where does this state live and who owns it? │ ├─ Only one component needs it │ └─ useState or useReducer (local state) │ ├─ A few nearby components need it │ └─ Lift state to nearest common ancestor + prop drilling │ (2-3 levels is fine) │ ├─ Many components need it, rarely changes │ (theme, locale, auth user) │ └─ React Context API │ Split contexts by update frequency │ Avoid single giant context │ ├─ Global client state, changes often │ (shopping cart, UI preferences, navigation) │ ├─ Simple/small app → Zustand (minimal boilerplate) │ ├─ Atomic updates, React Suspense integration → Jotai │ └─ Large team, time-travel debugging, complex logic → Redux Toolkit │ ├─ Server state (remote data, cache, sync) │ (API data, database queries) │ └─ TanStack Query (React Query) │ Handles: caching, background refetch, loading/error │ Don't use useState + useEffect for server data │ └─ Form state └─ React Hook Form + Zod validation (controlled inputs are fine for simple forms) ``` ## React 19 Quick Reference | Feature | API | Purpose | |---------|-----|---------| | `use()` hook | `use(promise)` / `use(context)` | Await promises in render, read context conditionally | | Actions | `async function action(formData)` | Async transitions with built-in pending state | | `useActionState` | `useActionState(action, initialState)` | Action result + pending state | | `useFormStatus` | `useFormStatus()` | Pending/data/method inside form | | `useOptimistic` | `useOptimistic(state, updateFn)` | Optimistic UI before server response | | React Compiler | Automatic memoization | Replaces most `memo`, `useMemo`, `useCallback` | | `ref` as prop | `<Input ref={ref}>` | No more forwardRef wrapper needed | | `<Context>` as provider | `<MyContext value={val}>` | No more `<MyContext.Provider>` | ```tsx // React 19: use() for data fetching in Server Components import { use } from 'react'; function UserProfile({ userPromise }: { userPromise: Promise<User> }) { const user = use(userPromise); // suspends until resolved return <h1>{user.name}</h1>; } // React 19: useActionState import { useActionState } from 'react'; function ContactForm() { const [state, action, isPending] = useActionState( async (prevState: State, formData: FormData) => { const result = await submitContact(formData); return result; }, { error: null } ); return ( <form action={action}> <input name="email" type="email" /> <button disabled={isPending}> {isPending ? 'Sending...' : 'Send'} </button> {state.error && <p>{state.error}</p>} </form> ); } ``` ## Server vs Client Components ``` Does this component need...? │ ├─ useState, useReducer, useContext │ └─ Client Component ('use client') │ ├─ useEffect, useLayoutEffect │ └─ Client Component ('use client') │ ├─ Browser APIs (window, document, localStorage) │ └─ Client Component ('use client') │ ├─ Event handlers (onClick, onChange, onSubmit) │ └─ Client Component ('use client') │ ├─ Third-party libraries that use hooks/browser APIs │ └─ Client Component ('use client') │ ├─ Direct database/file system access │ └─ Server Component (default, no directive) │ ├─ Access to env vars (server-only secrets) │ └─ Server Component │ ├─ Large dependencies you want to keep off the client bundle │ └─ Server Component │ └─ async/await at the top level └─ Server Component ``` **Client boundary rules:** - `'use client'` marks a boundary — everything imported below it becomes client JS - Server Components can import Client Components (they pass as props/children) - Client Components CANNOT import Server Components directly - Pass Server Component output as `children` prop to Client Components - Server data → Client: pass as serializable props only (no functions, classes, DOM nodes) ## Performance Checklist | Technique | When to Use | When NOT to Use | |-----------|-------------|-----------------| | `React.memo` | Component re-renders often with same props | Nearly everything — adds comparison overhead | | `useMemo` | Expensive calculation (>1ms), stable dep array | Primitive values, simple expressions | | `useCallback` | Callback passed to memoized child or in dep array | Inline handlers on DOM elements | | `React.lazy` + `Suspense` | Large components not needed on initial load | Small components, SSR-critical content | | `useTransition` | Non-urgent state updates (filtering, sorting) | Time-sensitive UI (typing, hover) | | `useDeferredValue` | Derived expensive render from fast-changing value | Same as above | | Virtualization | Lists >100 items | Small lists — overhead not worth it | | React Compiler (v19) | Automatic — replaces most manual memoization | Opt-out with `"use no memo"` if needed | ## Common Gotchas | Gotcha | Why It Happens | Fix | |--------|---------------|-----| | Stale closure in useEffect | Callback captures old state/prop at definition time | Add value to dep array, or use functional update `setState(prev => ...)` | | Missing useEffect dependency | Linter disabled or ignored, stale data shown | Never disable exhaustive-deps; use `useCallback` to stabilize functions | | Index as list key | Keys change on reorder/insert, causing wrong component identity | Use stable unique ID from data (`item.id`) | | Hydration mismatch | Server HTML doesn't match first client render | Avoid `typeof window`, random values, or dates in render; use `useEffect` for client-only content | | Unnecessary re-renders from context | All consumers re-render when any context value changes | Split context by concern; memoize context value with `useMemo` | | useEffect for derived state | State derived from another state causes extra render cycle | Compute derived value during render inline or with `useMemo` | | Missing cleanup in useEffect | Memory leaks from subscriptions, timers, fetch requests | Always return cleanup function; use AbortController for fetch | | Strict Mode double invocation | Effects run twice in dev to catch bugs | Design effects to be idempotent; cleanup must fully reverse effect | | Controlled/uncontrolled switch | `value` prop toggling between defined and `undefined` | Always provide defined value or always use `defaultValue`; never both | | Object/array in dep array | New reference every render triggers effect repeatedly | Memoize with `useMemo`; use primitive values in deps where possible | | Async function directly in useEffect | `useEffect(() => async () => {})` returns a Promise, not cleanup | Wrap: `useEffect(() => { async function run() {...}; run(); }, [])` | ## Reference Files | File | When to Load | |------|-------------| | `./references/hooks-patterns.md` | Deep hook usage: custom hooks, React 19 hooks, useEffect patterns, hook composition | | `./references/component-architecture.md` | Compound components, HOC, render props, portals, forwardRef, polymorphic components | | `./references/state-management.md` | Context API, Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form | | `./references/server-components.md` | RSC architecture, Server Actions, Next.js App Router, caching, streaming, metadata | | `./references/performance.md` | React.memo, code splitting, virtualization, React Compiler, Web Vitals, profiling | | `./references/testing.md` | RTL queries, user-event, MSW, renderHook, Vitest setup, accessibility testing | ## Staleness Verifier This skill encodes fast-moving facts (the React 19 API surface, the ecosystem package stack). [`scripts/check-react-facts.py`](scripts/check-react-facts.py) guards them against silent drift — internal consistency in PR CI, live major-version drift in the scheduled freshness job: ```bash # Structural (PR CI, no network): every catalogued package + React 19 gate is # still named in this skill's prose, and the currency note still carries a year. python3 skills/react-ops/scripts/check-react-facts.py --offline # exit 0 consistent, 10 drift # Live (weekly freshness job, never blocks a PR): is any documented major # now behind npm's latest dist-tag? python3 skills/react-ops/scripts/check-react-facts.py --live # exit 10 a major moved ahead, 7 npm unreachable ``` The canonical fact list lives in [`assets/react-facts.json`](assets/react-facts.json); when you add or drop a recommendation or the prose stops naming one, update it to match or `--offline` fails CI. ## See Also | Skill | When to Combine | |-------|----------------| | `typescript-ops` | TypeScript generics with React props, discriminated unions for state machines, utility types | | `testing-ops` | Test strategy, mocking patterns, CI integration, snapshot vs behavioral tests | | `tailwind-ops` | CSS-in-JS alternatives, responsive design with Tailwind in React components | | `javascript-ops` | Async patterns, Promises, generators, module system fundamentals |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.