| name | react-nextjs-antipatterns-skill |
| description | Detect and fix React 19 and Next.js 16 runtime anti-patterns — hydration mismatches, StrictMode double-execution, memory leaks from module-scope caches, stale derived state, fail-open RBAC, revalidatePath error swallowing, fragment keys, useMemo dep issues, and more |
| license | Apache-2.0 |
| compatibility | opencode |
| metadata | {"audience":"developers","workflow":"code-quality","languages":["typescript","javascript"],"frameworks":["react","nextjs"],"protocol":"autoresearch-opt-in"} |
What I do
I detect and fix runtime anti-patterns specific to React 19 and Next.js 16 that cause production incidents:
- Critical Anti-Patterns: Production-breaking issues — swallowed redirects, fail-open RBAC, stale derived state, StrictMode double-execution, dead route navigation
- Memory & Performance: Unbounded caches, useCallback/useMemo dependency traps, stale ref accumulators
- React-Specific: Fragment keys, unsafe event handlers, visibility toggle inconsistencies, duplicated mappings, type drift
- Next.js-Specific: Hydration mismatch elimination, cookie prefix management
- Recommended Patterns: Hook decomposition, theme-driven component design
- Testing: Playwright project routing
When to use me
Use this skill when:
- Debugging hydration mismatches or SSR rendering issues
- Auditing middleware for fail-open access control vulnerabilities
- Fixing stale state after external data mutations
- Investigating memory growth in long-lived SPAs
- Cleaning up duplicated status/type mappings across components
- Setting up Playwright multi-browser project routing
- Reviewing React/Next.js code for production-readiness
Related Skills
- accessibility-a11y-skill: ARIA patterns for dynamic error banners. This skill handles React anti-patterns.
- frontend-design-skill: UI aesthetics and layout. This skill handles runtime correctness.
- uiux-review-skill: Peer — axis 12 (Nielsen heuristic 4: consistency and standards) catches React components that drift from design-system patterns at runtime; axis 13 (anti-default AI cluster detection) flags generic-looking output. Use this skill for runtime anti-patterns; use
uiux-review-skill for visual/UX review of the rendered output.
- typescript-dry-principle-skill: Duplicate type definitions and status mappings. This skill covers the React-specific runtime impacts.
- security-audit-skill: Fail-open RBAC auditing. This skill covers the React/middleware implementation patterns.
- performance-optimization-skill: Module-scope cache leaks and N+1 queries. This skill covers the React-specific aspects.
Section A: Critical Anti-Patterns (Production-Breaking)
A1. revalidatepath-inside-generic-try-catch — Swallowed Redirects
revalidatePath() and redirect() throw non-Error objects with a digest property. Generic try/catch swallows them silently.
Before (broken):
try {
revalidatePath('/dashboard')
} catch (e) {
console.error('Failed to revalidate')
}
After (correct):
try {
revalidatePath('/dashboard')
} catch (e) {
if (e && typeof e === 'object' && 'digest' in e) {
const digest = e.digest as string
if (digest.startsWith('NEXT_REDIRECT') || digest.startsWith('NEXT_REVALIDATE')) {
throw e
}
}
console.error('Revalidation failed', e)
}
A2. fail-open-rbac-middleware — Default-Allow Access Control
When RBAC role extraction fails, requests pass through instead of being denied.
Before (vulnerable):
export function middleware(request: NextRequest) {
const role = extractRole(request)
if (role === 'admin') {
return NextResponse.next()
}
return NextResponse.next()
}
After (secure):
export function middleware(request: NextRequest) {
const role = extractRole(request)
if (!isValidRole(role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.next()
}
A3. derived-state-props-without-sync — Stale useState from Props
useState initialized from props becomes stale when the parent updates the prop.
Before (stale):
function EditDialog({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName)
}
After (synced):
function EditDialog({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName)
useEffect(() => {
setName(initialName)
}, [initialName])
}
A4. ref-guard-early-return — StrictMode Double-Execution
useRef(false) guard with early returns before setting ref causes double-execution in React StrictMode.
Before (buggy):
function useInit() {
const started = useRef(false)
useEffect(() => {
if (someCondition) return
if (started.current) return
started.current = true
init()
}, [])
}
After (correct):
function useInit() {
const started = useRef(false)
useEffect(() => {
if (started.current) return
started.current = true
if (someCondition) return
init()
}, [])
}
A5. route-removal-misses-runtime-nav — Dead Route References
Import-based dead code analysis misses runtime navigation to deleted routes.
Detection pattern:
rg "router\.push\(['\"](/old-route)" --type ts --type tsx
rg "href=['\"](/old-route)" --type ts --type tsx
rg "router\.replace\(['\"](/old-route)" --type ts --type tsx
Section B: Memory & Performance
B1. module-scope-map-cache — Unbounded Module-Level Map
Module-level Map grows without bound in SPA lifecycle.
Before (leak):
const userCache = new Map<string, User>()
function getUser(id: string) {
if (!userCache.has(id)) {
userCache.set(id, fetchUser(id))
}
return userCache.get(id)
}
After (bounded):
const MAX_CACHE_SIZE = 100
const userCache = new Map<string, User>()
function getUser(id: string) {
if (userCache.has(id)) return userCache.get(id)!
if (userCache.size >= MAX_CACHE_SIZE) {
const firstKey = userCache.keys().next().value
userCache.delete(firstKey)
}
const user = fetchUser(id)
userCache.set(id, user)
return user
}
B2. loading-state-in-usecallback-deps — Unnecessary Callback Recreation
Including loading booleans in useCallback deps causes unnecessary recreation.
Before (inefficient):
const handleSubmit = useCallback(() => {
if (loading) return
submit()
}, [loading, submit])
After (efficient):
const handleSubmit = useCallback(() => {
submit()
}, [submit])
<button onClick={handleSubmit} disabled={loading}>Submit</button>
B3. inline-computed-usememo-dep — New Reference Every Render
Inline ternary/computed values as useMemo deps create new references every render.
Before (stale):
const sorted = useMemo(() => sortItems(items), [items.length > 0 ? items : []])
After (correct):
const effectiveItems = useMemo(() => (items.length > 0 ? items : []), [items])
const sorted = useMemo(() => sortItems(effectiveItems), [effectiveItems])
B4. reset-refs-on-effect-restart — Stale Ref Accumulators
useRef accumulators in useEffect carry over from previous lifecycles.
Before (buggy):
useEffect(() => {
const counter = useRef(0)
counter.current++
}, [dependency])
After (correct):
const counter = useRef(0)
useEffect(() => {
counter.current = 0
}, [dependency])
Section C: React-Specific
C1. fragment-key-in-map — Missing List Keys
<> shorthand Fragment in .map() can't accept key.
Before (warning):
{items.map((item) => (
<>
<span>{item.name}</span>
<span>{item.value}</span>
</>
))}
After (correct):
import { Fragment } from 'react'
{items.map((item) => (
<Fragment key={item.id}>
<span>{item.name}</span>
<span>{item.value}</span>
</Fragment>
))}
C2. unsafe-json-parse-event-handler — UI Crash on Malformed Data
JSON.parse in drag-and-drop handlers crashes UI on malformed data.
Before (crashes):
function onDrop(e: DragEvent) {
const data = JSON.parse(e.dataTransfer.getData('text'))
handleDrop(data)
}
After (safe):
function onDrop(e: DragEvent) {
try {
const data = JSON.parse(e.dataTransfer.getData('text'))
handleDrop(data)
} catch {
showToast('Invalid drag data')
}
}
C3. inconsistent-visibility-toggle-strategy — Mixed Hide Approaches
Mixing hard-removal with runtime isXxxVisible() filtering causes confusion.
Before (inconsistent):
items = items.filter(i => i.id !== removedId)
{items.filter(i => isFeatureVisible(i.id)).map(...)}
After (consistent):
const visibleItems = items.filter(i => isVisible(i.id))
{visibleItems.map(...)}
C4. duplicated-status-mappings — Copy-Paste Status Logic
Near-identical status→icon/color switch statements in 3+ files.
Before (duplicated):
const icon = status === 'active' ? '✅' : status === 'pending' ? '⏳' : '❌'
After (shared utility):
export function getStatusIcon(status: string, size: 'sm' | 'md' = 'md') {
const icons = { active: '✅', pending: '⏳', failed: '❌' }
return icons[status] ?? '❓'
}
C5. duplicate-type-definitions — Drifting Local Types
Local component types duplicating canonical types drift apart.
Before (drifts):
interface User { id: string; name: string }
export interface User { id: string; name: string; email: string }
After (single source):
export interface User { id: string; name: string; email: string }
import type { User } from '@/types/user'
C6. toast-promise-await-without-catch — Double Consumer on toast.promise
toast.promise(apiCall(), { ... }) and a bare await apiCall() are two INDEPENDENT consumers of the same promise. The toast wrapper handles the rejection (shows the error UI), but the bare await still lets the rejection propagate up the call stack. If the containing function is called from a path that has no try/catch, the unhandled rejection fires a Sentry alert for an error the user has already seen and dismissed. Add an empty .catch(() => {}) to the awaited promise to swallow the already-surfaced error, OR drop the await entirely if only the toast UX is needed.
import { toast } from 'sonner'
async function handleSubmit() {
toast.promise(apiCall(), {
loading: 'Saving...',
success: 'Saved',
error: 'Save failed',
})
await apiCall()
}
async function handleSubmit() {
toast.promise(apiCall(), {
loading: 'Saving...',
success: 'Saved',
error: 'Save failed',
})
await apiCall().catch(() => {})
}
async function handleSubmit() {
const promise = apiCall()
toast.promise(promise, { loading: 'Saving...', success: 'Saved', error: 'Save failed' })
try {
await promise
} catch {
}
}
Detection:
rg "toast\.promise" --type ts --type tsx -A 8 | rg "await" | rg -v "catch|try"
Rule: toast.promise() and a bare await of the same promise are two independent consumers. If the toast handles the error UI, add .catch(() => {}) to the await (or drop the await) to prevent the rejection from being re-surfaced as Sentry noise for an error the user has already seen.
After (single source):
import type { User } from '@/types/user'
Section D: Next.js-Specific
D1. ssr-false-eliminates-hydration-mismatch — No More typeof window Guards
Wrap browser-API components in next/dynamic({ ssr: false }) to eliminate hydration mismatches.
import dynamic from 'next/dynamic'
const MapComponent = dynamic(() => import('./Map'), { ssr: false })
D2. chunked-cookie-secure-prefix-mismatch — Shared Cookie Utility Only
Always use a shared cookie utility — never roll your own __Secure- prefix logic.
export function setSecureCookie(name: string, value: string, opts?: CookieOptions) {
const prefix = location.protocol === 'https:' ? '__Secure-' : ''
document.cookie = `${prefix}${name}=${value}; ${serializeOpts(opts)}`
}
Section E: Recommended Patterns
E1. hook-decomposition-complex-component — Focused Hooks
Decompose complex components into focused hooks with typed interfaces.
function ReportForm() { }
function useReportState(initial?: Report) { }
function useReportValidation(values: Report) { }
function useReportSubmit() { }
function ReportForm({ initial }: { initial?: Report }) {
const { values, setField } = useReportState(initial)
const errors = useReportValidation(values)
const { submit, isSubmitting } = useReportSubmit()
}
E2. folder-tabs-theme-driven — CSS Custom Properties
CSS custom properties only — no hardcoded colors, automatic light/dark mode.
<div className="tab-bar" style={{ '--tab-active-bg': 'var(--color-primary)' }}>
<button className="tab active">Overview</button>
</div>
.tab { background: var(--tab-bg, transparent); }
.tab.active { background: var(--tab-active-bg); color: var(--tab-active-fg); }
:root[data-theme="dark"] { --color-primary: #6366f1; }
:root[data-theme="light"] { --color-primary: #4f46e5; }
Section F: Testing
F1. browserName-playwright-project-routing — Project vs Browser
browserName is the browser engine, not the project name. Use testInfo.project.name for multi-project routing.
Before (wrong routing):
test('works in all projects', ({ browserName }) => {
if (browserName === 'chromium') {
}
})
After (correct routing):
test('works in all projects', ({}, testInfo) => {
if (testInfo.project.name === 'desktop-chrome') {
}
})
Iteration Protocol (opt-in)
DO NOT execute any of the following unless AUTORESEARCH_PROTOCOL=1 is set in your environment. When unset, this skill behaves exactly as documented in all sections above; the Iteration Protocol block is descriptive only.
When AUTORESEARCH_PROTOCOL=1:
Auto-detection
If invoked on an iterative task, prompt ONCE per session: "This looks iterative. Enable autoresearch protocol? (y/n)". Cache answer for session.
Skill-specific patterns
Detect→fix→revert cycle. Each iteration: detect anti-pattern (regex/AST), apply fix, run hydration/runtime check, emit {"pass":bool,"score":N} (pass = no regression introduced; score = anti-patterns removed). Revert on pass:false via git reset --hard HEAD~1. Log to react-antipatterns-results.tsv. See evaluator-contract.md + audit-trail.md.
Citations
autoresearch-core-skill/references/evaluator-contract.md
autoresearch-core-skill/references/audit-trail.md
Imperative gating
When AUTORESEARCH_PROTOCOL is unset, this section is descriptive only. Default behavior is documented in all sections above.