mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
* fix(skills): move version into metadata and normalize to semver 29 skills declared `version` at the top level of their frontmatter. The schema reads it from `metadata`, so tooling that follows the schema either misses it or has to special-case the top level. Three motion skills also declared `version: 1.0`, which is not a valid semantic version; normalized to `1.0.0`. No behavioral change — frontmatter metadata only. * fix(skills): state activation triggers in skill descriptions 148 skills described what they cover but never named the situation that should trigger them. Since the description is what Claude matches against to decide whether to load a skill, a description without a trigger makes activation guesswork — the skill is either missed or loaded at the wrong time. Added a "Use when ..." clause to each, derived from the skill's own body (most already stated the trigger under "## When to Use" or in the opening line; that intent is now reflected in the frontmatter where it is actually read from). Descriptions were only appended to; no existing wording was removed. * fix(skills): sync activation triggers into the Codex skill mirror 10 of the skills whose descriptions changed are also mirrored under `.agents/skills/`, where the description was previously a verbatim copy. Left alone, the two surfaces would disagree about when the skill applies. Only the description line is synced; the Codex copies keep their reduced frontmatter, since that validator accepts only name, description, metadata, license, and allowed-tools. * fix(skills): correct three activation clauses from review - autonomous-loops: the clause pulled new loop work into a skill that its own body marks as a compatibility shim retained for one release. It now points at the canonical continuous-agent-loop instead. - continuous-learning: the description carried the v1 routing directive twice; collapsed to one. - homelab-pihole-dns: the clause fired on any broken home DNS. Narrowed to tasks that actually involve Pi-hole. * chore: retain current main lockfile --------- Co-authored-by: Çağrı Solakoğlu <cagri.solakoglu@vtcenerji.com> Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
658 lines
15 KiB
Markdown
658 lines
15 KiB
Markdown
---
|
|
name: frontend-patterns
|
|
description: Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. Use when building or reviewing React or Next.js components, state, or render performance.
|
|
metadata:
|
|
origin: ECC
|
|
---
|
|
|
|
# Frontend Development Patterns
|
|
|
|
Modern frontend patterns for React, Next.js, and performant user interfaces.
|
|
|
|
## When to Activate
|
|
|
|
- Building React components (composition, props, rendering)
|
|
- Managing state (useState, useReducer, Zustand, Context)
|
|
- Implementing data fetching (SWR, React Query, server components)
|
|
- Optimizing performance (memoization, virtualization, code splitting)
|
|
- Working with forms (validation, controlled inputs, Zod schemas)
|
|
- Handling client-side routing and navigation
|
|
- Building accessible, responsive UI patterns
|
|
|
|
## Component Patterns
|
|
|
|
### Composition Over Inheritance
|
|
|
|
```typescript
|
|
// PASS: 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)
|
|
|
|
// Keep the latest fetcher/options in refs so refetch stays referentially
|
|
// stable even when callers pass inline functions and object literals.
|
|
// Without this, every render creates a new refetch, and the effect below
|
|
// re-runs after each state update - an infinite fetch loop.
|
|
const fetcherRef = useRef(fetcher)
|
|
const optionsRef = useRef(options)
|
|
useEffect(() => {
|
|
fetcherRef.current = fetcher
|
|
optionsRef.current = options
|
|
})
|
|
|
|
const refetch = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const result = await fetcherRef.current()
|
|
setData(result)
|
|
optionsRef.current?.onSuccess?.(result)
|
|
} catch (err) {
|
|
const error = err as Error
|
|
setError(error)
|
|
optionsRef.current?.onError?.(error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const enabled = options?.enabled !== false
|
|
|
|
useEffect(() => {
|
|
if (enabled) {
|
|
refetch()
|
|
}
|
|
}, [key, enabled, refetch])
|
|
|
|
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
|
|
// PASS: useMemo for expensive computations
|
|
// Copy before sorting - Array.prototype.sort mutates in place
|
|
const sortedMarkets = useMemo(() => {
|
|
return [...markets].sort((a, b) => b.volume - a.volume)
|
|
}, [markets])
|
|
|
|
// PASS: useCallback for functions passed to children
|
|
const handleSearch = useCallback((query: string) => {
|
|
setSearchQuery(query)
|
|
}, [])
|
|
|
|
// PASS: 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'
|
|
|
|
// PASS: 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'
|
|
|
|
// PASS: 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>
|
|
)
|
|
}
|
|
|
|
// PASS: 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.
|