React/Next.js structural patterns — component composition, custom hooks, state layering, data fetching, memoization, virtualization, forms, and error boundaries — as practised in the devskyy dashboard at frontend/. Use when building or reviewing a component, hook, API route, or state container under frontend/ (Next.js 16 + React 19). Do NOT use for the WordPress theme (no React — that is skyyrose-wp-platform / css-cascade-discipline), and do NOT use for animation specifics (motion-ui) or acce...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add SkyyRoseLLC/DevSkyy --skill frontend-patterns --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Frontend Patterns?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/skyyrosellc-frontend-patterns)More formats (shields.io, HTML) on the badges page.
---
name: frontend-patterns
description: React/Next.js structural patterns — component composition, custom hooks, state layering, data fetching, memoization, virtualization, forms, and error boundaries — as practised in the devskyy dashboard at frontend/. Use when building or reviewing a component, hook, API route, or state container under frontend/ (Next.js 16 + React 19). Do NOT use for the WordPress theme (no React — that is skyyrose-wp-platform / css-cascade-discipline), and do NOT use for animation specifics (motion-ui) or accessibility semantics (frontend-a11y).
origin: ECC
---
# Frontend Development Patterns
Modern frontend patterns for React, Next.js, and performant user interfaces.
## When to use
**Observable events:**
- You are adding or editing a component, hook, or context under `frontend/app/`, `frontend/components/`, or `frontend/lib/`.
- You are adding an API route under `frontend/app/api/**/route.ts` — the auth-coverage gate will reject it unwrapped (see Verification check 3).
- A dashboard list renders hundreds of rows and scrolling stutters (virtualization).
- A component re-renders on every parent update (memoization).
- You are choosing where a piece of state lives — server cache, persistent store, or session.
**When NOT to use:**
- **`wordpress-theme/skyyrose-flagship/`** — PHP templates and vanilla JS. There is no React, no hooks, no bundler-scoped CSS. Use `skyyrose-wp-platform` for the theme and `css-cascade-discipline` for its styles.
- **Animation behaviour** — `AnimatePresence`, tokens, reduced motion: `motion-ui`.
- **Accessibility semantics** — labels, ARIA, focus order: `frontend-a11y`.
- **Trivial single-line edits** where the pattern is already established in the file. Match the file, do not restructure it.
## Inputs
| Required before starting | How to confirm | If absent |
|---|---|---|
| `frontend/node_modules` installed | `ls -d frontend/node_modules` | **STOP.** Run `npm install` — **npm, never pnpm** (`ERR_INVALID_THIS` on Node 22+ breaks Vercel deploys). No checks below can run without it. |
| Which state layer owns this data | read `frontend/CLAUDE.md` → "State management — three layers" | **STOP.** Guessing produces a fourth parallel store. Server/API data → TanStack Query; persistent cart → Zustand `persist`; admin auth → NextAuth `useSession()`. `jotai` is installed but unused — do not introduce atoms without confirming the domain is not already one of the three. |
| Whether the module is server-only | grep the file for `node:fs`, `server-only`, `next-auth` | **STOP.** `lib/catalog.ts` uses `node:fs` (`catalog.ts:13`); importing it from a `'use client'` component **crashes the build**. Client code must call `/api/catalog`. |
| For a new API route: its auth decision | read `frontend/lib/api-auth.ts` and `lib/api-public-routes.ts` | **STOP.** Do not add to `PUBLIC_API_ROUTES` without writing the reason beside it. Per-handler auth is fail-OPEN by nature; the coverage test is what restores fail-closed. |
| For new tests: the runner's `include` list | read `frontend/vitest.config.ts` | **STOP.** The config uses an explicit `include` (`lib/wp/**`, `tests/**`), not a glob, because most of the dashboard cannot be imported under vitest. A suite written outside those paths is silently **skipped** — and a skipped security test is indistinguishable from a passing one. |
## Procedure
1. **Read the target file and its neighbours first.** Match the existing pattern; do not introduce a second way of doing what the file already does.
2. **Place the state in the right layer** (table above). Server data does not belong in `useState`.
3. **Import through the barrel** — always `@/lib/api`, never `@/lib/api/endpoints/*` directly. To add an endpoint: file in `lib/api/endpoints/`, Zod schema in `lib/api/schemas.ts`, register in `lib/api/index.ts`.
4. **Keep the server/client boundary intact.** Anything touching `node:fs`, `next-auth`, or `next/server` stays server-side; client components call the REST route instead.
5. **Wrap every new API handler**: `export const GET = withAuth(getHandler);`. Unauthenticated must return **401 JSON, never a redirect** — a 302 to `/login` is followed transparently and hands the caller an HTML page with status 200, which parses as success.
6. **Reach for memoization only against a measured re-render**, not preemptively. `useMemo`/`useCallback` on cheap values costs more than it saves.
7. **Virtualize lists past a few hundred rows** (`@tanstack/react-virtual`), rather than paginating a UI that should scroll.
8. **Validate at the boundary with Zod**, and derive the TypeScript type from the schema rather than declaring it twice.
9. **Run the Verification checks below** and paste real output.
## Component Patterns
### Composition Over Inheritance
```typescript
// ✅ GOOD: Component composition
interface CardProps {
children: React.ReactNode
variant?: 'default' | 'outlined'
}
export function Card({ children, variant = 'default' }: CardProps) {
return <div className={`card card-${variant}`}>{children}</div>
}
export function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
export function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
// Usage
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content</CardBody>
</Card>
```
### Compound Components
```typescript
interface TabsContextValue {
activeTab: string
setActiveTab: (tab: string) => void
}
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ children, defaultTab }: {
children: React.ReactNode
defaultTab: string
}) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
export function TabList({ children }: { children: React.ReactNode }) {
return <div className="tab-list">{children}</div>
}
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
const context = useContext(TabsContext)
if (!context) throw new Error('Tab must be used within Tabs')
return (
<button
className={context.activeTab === id ? 'active' : ''}
onClick={() => context.setActiveTab(id)}
>
{children}
</button>
)
}
// Usage
<Tabs defaultTab="overview">
<TabList>
<Tab id="overview">Overview</Tab>
<Tab id="details">Details</Tab>
</TabList>
</Tabs>
```
### Render Props Pattern
```typescript
interface DataLoaderProps<T> {
url: string
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [url])
return <>{children(data, loading, error)}</>
}
// Usage
<DataLoader<Market[]> url="/api/markets">
{(markets, loading, error) => {
if (loading) return <Spinner />
if (error) return <Error error={error} />
return <MarketList markets={markets!} />
}}
</DataLoader>
```
## Custom Hooks Patterns
### State Management Hook
```typescript
export function useToggle(initialValue = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
setValue(v => !v)
}, [])
return [value, toggle]
}
// Usage
const [isOpen, toggleOpen] = useToggle()
```
### Async Data Fetching Hook
```typescript
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
export function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options?: UseQueryOptions<T>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await fetcher()
setData(result)
options?.onSuccess?.(result)
} catch (err) {
const error = err as Error
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}, [fetcher, options])
useEffect(() => {
if (options?.enabled !== false) {
refetch()
}
}, [key, refetch, options?.enabled])
return { data, error, loading, refetch }
}
// Usage
const { data: markets, loading, error, refetch } = useQuery(
'markets',
() => fetch('/api/markets').then(r => r.json()),
{
onSuccess: data => console.log('Fetched', data.length, 'markets'),
onError: err => console.error('Failed:', err)
}
)
```
### Debounce Hook
```typescript
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
useEffect(() => {
if (debouncedQuery) {
performSearch(debouncedQuery)
}
}, [debouncedQuery])
```
## State Management Patterns
### Context + Reducer Pattern
```typescript
interface State {
markets: Market[]
selectedMarket: Market | null
loading: boolean
}
type Action =
| { type: 'SET_MARKETS'; payload: Market[] }
| { type: 'SELECT_MARKET'; payload: Market }
| { type: 'SET_LOADING'; payload: boolean }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_MARKETS':
return { ...state, markets: action.payload }
case 'SELECT_MARKET':
return { ...state, selectedMarket: action.payload }
case 'SET_LOADING':
return { ...state, loading: action.payload }
default:
return state
}
}
const MarketContext = createContext<{
state: State
dispatch: Dispatch<Action>
} | undefined>(undefined)
export function MarketProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, {
markets: [],
selectedMarket: null,
loading: false
})
return (
<MarketContext.Provider value={{ state, dispatch }}>
{children}
</MarketContext.Provider>
)
}
export function useMarkets() {
const context = useContext(MarketContext)
if (!context) throw new Error('useMarkets must be used within MarketProvider')
return context
}
```
## Performance Optimization
### Memoization
```typescript
// ✅ useMemo for expensive computations
const sortedMarkets = useMemo(() => {
return markets.sort((a, b) => b.volume - a.volume)
}, [markets])
// ✅ useCallback for functions passed to children
const handleSearch = useCallback((query: string) => {
setSearchQuery(query)
}, [])
// ✅ React.memo for pure components
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
return (
<div className="market-card">
<h3>{market.name}</h3>
<p>{market.description}</p>
</div>
)
})
```
### Code Splitting & Lazy Loading
```typescript
import { lazy, Suspense } from 'react'
// ✅ Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
export function Dashboard() {
return (
<div>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
<Suspense fallback={null}>
<ThreeJsBackground />
</Suspense>
</div>
)
}
```
### Virtualization for Long Lists
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 5 // Extra items to render
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
```
## Form Handling Patterns
### Controlled Form with Validation
```typescript
interface FormData {
name: string
description: string
endDate: string
}
interface FormErrors {
name?: string
description?: string
endDate?: string
}
export function CreateMarketForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
endDate: ''
})
const [errors, setErrors] = useState<FormErrors>({})
const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Name is required'
} else if (formData.name.length > 200) {
newErrors.name = 'Name must be under 200 characters'
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required'
}
if (!formData.endDate) {
newErrors.endDate = 'End date is required'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
try {
await createMarket(formData)
// Success handling
} catch (error) {
// Error handling
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Market name"
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* Other fields */}
<button type="submit">Create Market</button>
</form>
)
}
```
## Error Boundary Pattern
```typescript
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = {
hasError: false,
error: null
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error boundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage
<ErrorBoundary>
<App />
</ErrorBoundary>
```
## Animation Patterns
### Framer Motion Animations
```typescript
import { motion, AnimatePresence } from 'framer-motion'
// ✅ List animations
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
return (
<AnimatePresence>
{markets.map(market => (
<motion.div
key={market.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MarketCard market={market} />
</motion.div>
))}
</AnimatePresence>
)
}
// ✅ Modal animations
export function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
className="modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
className="modal-content"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
)
}
```
## Accessibility Patterns
### Keyboard Navigation
```typescript
export function Dropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setActiveIndex(i => Math.min(i + 1, options.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActiveIndex(i => Math.max(i - 1, 0))
break
case 'Enter':
e.preventDefault()
onSelect(options[activeIndex])
setIsOpen(false)
break
case 'Escape':
setIsOpen(false)
break
}
}
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
onKeyDown={handleKeyDown}
>
{/* Dropdown implementation */}
</div>
)
}
```
### Focus Management
```typescript
export function Modal({ isOpen, onClose, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement
// Focus modal
modalRef.current?.focus()
} else {
// Restore focus when closing
previousFocusRef.current?.focus()
}
}, [isOpen])
return isOpen ? (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={e => e.key === 'Escape' && onClose()}
>
{children}
</div>
) : null
}
```
**Remember**: Modern frontend patterns enable maintainable, performant user interfaces. Choose patterns that fit your project complexity.
---
## Verification
Run from `frontend/`. Three independent checks, each able to return "no".
1. **Types compile.** Catches the boundary violations this codebase actually hits — a server-only import pulled into a client component, a prop shape drifting from its Zod schema, a hook returning the wrong tuple.
```bash
cd frontend && npx tsc --noEmit
```
**PASS:** exits 0 and prints `TypeScript: No errors found`.
**Observed 2026-07-28: `TypeScript: No errors found`** `[repro]`.
2. **Unit suites still green.**
```bash
cd frontend && npx vitest run lib/wp/__tests__/throttle.test.ts
```
**PASS:** `PASS (3) FAIL (0)`.
**Observed 2026-07-28: `PASS (3) FAIL (0)`** `[test]`.
3. **Every API route is gated — the fail-closed gate (rule 2 in code form).** This suite walks `app/api/**/route.ts` on disk and rejects any exported handler that is neither `withAuth`-wrapped nor explicitly exempted. It is the reason a forgotten route cannot ship unprotected.
```bash
cd frontend && npx vitest run tests/api-auth-coverage.test.ts
```
**PASS:** `PASS (37) FAIL (0)` — and the count *rises* when you add a route. A new route that does not raise it was not detected.
**Observed 2026-07-28: `PASS (37) FAIL (0)`** `[test]`.
**Prove the check can fail (rule 3).** Check 3 is the one worth breaking once: add a throwaway
`app/api/__proof/route.ts` exporting a bare `export async function GET() {}`, re-run the suite, confirm
it goes **red** naming that route, then delete the file and confirm green again. A gate never observed
failing is a guess with a citation.
**A gate that dies is not a gate that passed (rule 1).** `vitest` exits non-zero for a *config* error
(bad `include`, unresolvable import) exactly as it does for a failing assertion, and it exits **zero**
when its `include` matches nothing. `No test files found` is therefore an artifact, not a pass — read
the counts, not just the exit code. (bug-230, ×6.)
**A SKIP is not a PASS (rule 2).** `vitest.config.ts` deliberately excludes `tests/e2e/**` (Playwright
owns it) and cannot import anything behind `server-only` or `next-auth`. So request-path behaviour —
does the admin page actually render, does the 401 actually return JSON — is **unverified by the above**.
Closer: `npm run test:e2e` (Playwright, `tests/e2e/`) or the `run-devskyy-dashboard` skill for a booted
smoke. Do not report a route as working on type-check evidence alone.
**Attribution (rule 4).** These three are green in the pristine tree, so any red is presumptively yours —
but confirm rather than assume when the failure looks unrelated:
```bash
mkdir -p /tmp/fe-attr && git archive HEAD frontend/lib frontend/tests | tar -x -C /tmp/fe-attr
```
Compare against that copy. **Never `git stash`** — the stack is shared across worktrees.
## Worked example
**Task (2026-07-28):** confirm the dashboard's structural gates are green before adding a hook.
```bash
$ cd frontend && npx tsc --noEmit
TypeScript: No errors found
$ npx vitest run lib/wp/__tests__/throttle.test.ts
PASS (3) FAIL (0)
$ npx vitest run tests/api-auth-coverage.test.ts
PASS (37) FAIL (0)
```
All three green `[test]`. The 37-case count is the load-bearing number: it is one assertion per
route handler discovered **on disk**, so it tracks the filesystem rather than a hand-maintained list.
That is what makes it fail closed — adding `app/api/foo/route.ts` without `withAuth` turns it red
without anyone remembering to update a test.
**Honest scope:** this proves the committed tree type-checks and its unit gates pass `[test]`. It does
**not** prove the dashboard renders, that auth works against a live session, or that anything is correct
on `devskyy.app` — those need Playwright and a deployment probe respectively. Reporting "the dashboard
is working" from this output would be the `[repo]`/`[test]` → `[live]` jump the evidence rules ban
(bug-287).
## Failure modes
| Symptom | Root cause | Fix |
|---|---|---|
| Build crashes importing catalog data into a component | `lib/catalog.ts` uses `node:fs` (`catalog.ts:13`); pulled into a `'use client'` tree | Call `/api/catalog` from the client instead |
| A `fetch` from an admin page returns HTML with status 200 | Unauthenticated API redirected 302 → `/login`, transparently followed | Handlers must return **401 JSON**; wrap with `withAuth()` |
| New API route ships unprotected | Per-handler auth is fail-open by construction | `tests/api-auth-coverage.test.ts` — never bypass it by adding to `PUBLIC_API_ROUTES` without a written reason |
| A new test suite "passes" but never ran | `vitest.config.ts` uses an explicit `include`; the file is outside it | Put suites in `lib/wp/**` or `tests/**`, keep them framework-free, and check the count rose |
| `useAuth()` returns undefined in an admin page | Two auth systems: admin is NextAuth (`useSession()`), storefront is `AuthContext` | Use `useSession()` under `/admin/*` |
| Vercel build crashes on a doubled path | `outputFileTracingRoot` / `turbopack.root` set unconditionally | Leave the `!process.env.VERCEL` guard in `next.config.ts:13` alone |
| Deploy fails with `ERR_INVALID_THIS` | pnpm on Node 22+ | Use npm |
| Direct import from `@/lib/api/endpoints/*` compiles but the page 404s the call | Barrel bypassed; endpoint never registered in `lib/api/index.ts` | Import from `@/lib/api`; register the endpoint |
| Memoization added, nothing got faster | `useMemo`/`useCallback` on cheap values | Measure first; remove speculative memoization |
| A killed `vitest` run reported as clean | Gate died — output is an artifact | Re-run; read counts not exit code (bug-230) |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!