| paths |
|
|---|
- Components: Arrow function components:
const X = () =>. Class components deprecated. - State:
useState(local),useReducer(complex state),useActionState(form actions) - Data fetching:
use()hook for promises (React 19+), custom hooks for data fetching - Composition: Prefer over inheritance, compound patterns (Card.Header, Card.Body)
- Effect events:
useEffectEventfor values an effect reads but must not react to — keeps them out of the dependency array without suppressing the lint rule - Imports: Named imports only -
import { useState, useEffect } from 'react' - Legacy imports: ALWAYS replace
import * as React from 'react'andimport React from 'react'with named imports
- Handler naming:
handleXfor internal event handlers,onXfor callback prop names - Hook naming: Always prefix with
useif calling other hooks - Purpose-specific hooks: Name after purpose (e.g.,
useChatRoom,useAuth), not lifecycle (useMount) - Exported prop interfaces: Name
<ComponentName>Props, NOT a bareProps. A barePropspollutes the importing file's namespace and collides when multiple component prop interfaces are imported. Internal (non-exported) prop interfaces may usePropsfor brevity since they don't cross file boundaries.
// ❌ WRONG - bare Props pollutes importing files
export interface Props {
open: boolean
onClose: () => void
}
// ✅ CORRECT - prefixed with component name
export interface ChangeNameDialogProps {
open: boolean
onClose: () => void
}
// ✅ OK - non-exported interface can stay bare
interface Props {
open: boolean
}
const ChangeNameDialog = ({ open }: Props) => { ... }Where the React Compiler is enabled (reactCompiler: true in the Next.js config), it memoizes for you: hand-written useMemo / useCallback / memo() are the exception there, and each one needs a stated reason for why the compiler's output isn't sufficient. Check the config before adding one. The rest of this section is the decision tree for projects without the compiler.
Use memoization only when there is a measured performance need or a specific technical requirement:
memo(): Wrap with named function expression for DevTools:export const X = memo(function X(...) { })useCallback: Only when passing tomemo()children, used as hook dependencies, or returned from custom hooks. Do NOT wrap handlers passed to plain DOM elements or non-memo components.useMemo: Only for expensive computations (>1ms), stabilizing props formemo()children, or values used as hook dependencies. Do NOT use for trivial calculations.- State updater functions: Use updater form in
useCallback(setItems(prev => [...prev, item])) to avoid including state in dependency arrays - Incomplete memoization chain: If a component is wrapped with
memo(), ALL props passed to it must be stable (memoized or primitive). Memoizing some props but not all silently breaksmemo().
When reviewing code, do not surface a finding (Critical/High/Medium/Nice-to-Have) recommending useMemo or useCallback purely for referential stability of a value or function passed to a child component that is not memo()-wrapped. Such cleanups produce code churn without measurable benefit — the child re-renders either way, the memo's recomputation cost roughly equals the inline expression's, and the surrounding handler/prop chain typically isn't memoized either.
A memoization finding is legitimate ONLY when at least one of these holds:
- The child is already
memo()-wrapped (and the new memo completes the prop-stability chain — all other props must also be stable, otherwisememo()is silently broken anyway). - The value is used as a hook dependency upstream, where identity changes would re-fire the effect/memo unnecessarily.
- The computation is measurably expensive (>1ms — name the measurement or the input scale that justifies the threshold).
Otherwise, mention it in the "Approved" section as a deliberate non-finding ("inline expression is fine here — child is not memoized, no hook dep") rather than as Nice-to-Have. This prevents trivial memoization tickets from being filed as deferred work that the rules above would tell you not to do in the first place.
- Dependencies: Always include all dependencies in
useEffectarrays - Functions in effects: Define functions inside
useEffectto avoiduseCallback - Objects in effects: Create objects inside
useEffectto avoiduseMemo - No dependency suppression: Never suppress
exhaustive-depslinter warnings — when the effect must read a value without re-firing on it, move that read into auseEffectEventcallback and call it from the effect
- Catch inside the callback:
startTransition(async () => { try { await refetch() } catch (e) { ... } }). Applies to every rejectable promise handed to a transition — Apollorefetch(), auseLazyQueryexecute, a rawfetch— not just the one that looks risky. - Why: a rejection inside
useTransition'sstartTransitionbecomes the hook's pending state and is re-thrown DURING RENDER fromuseTransition()itself on the next render, so only the nearest error boundary catches it — never a local try/catch, and never the inline error-state fallback the component uses elsewhere. (The top-levelstartTransitionimported fromreactdiffers: rejections there go to a global error report and reach no boundary.)
- ❌ Higher-order hooks or passing hooks as props
- ❌ Chaining effects to update interdependent state
- ❌ Creating objects/functions in dependency arrays without memoization
- ❌
forwardRef(deprecated in React 19+, userefprop directly)