React / Senior Frontend Engineering
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/frontend/references/react.md |
| Description | Not specified |
Source Content
React / Senior Frontend Engineering
Senior React specialist material: React 19, Server Components, state-location decisions, hooks, testing, and the day-to-day craft of typed, testable, accessible UI. Absorbed from the former senior-frontend skill (plus its bundled frontend_best_practices.md, react_patterns.md, and nextjs_optimization_guide.md references).
Use for
- New React components and features in a TypeScript codebase.
- State decisions: local vs URL vs Context vs Zustand vs server cache.
- React 19 work —
use(),useActionState, async transitions, Server Components. - Data fetching with TanStack Query and Router loaders.
- Hook design and effect cleanup; converting class components to hooks/RSC.
Don’t use for
- Visual direction, hero pages, aesthetic work →
references/frontend-design.md. - Design tokens, primitives, theming →
ui-design-systemskill. - Performance audits and bundle budgets →
references/react-perf.md. - Route topology, search params, loaders-as-data-graph →
references/tanstack-router.md.
How to work
- Analyze. Component hierarchy, where state lives, where data comes from, what re-renders.
- Pick patterns. State location (local / URL / Zustand / Query). Data fetching (loader /
use()/ Query). Server vs Client component. - Implement. TypeScript strict, semantic HTML,
keys stable, effects cleaned up, Suspense boundaries for async. - Type-check.
tsc --noEmit. Fix every error before continuing. Don’t paper over withanyoras. - Test. React Testing Library; integration over unit. Assert behavior the user can observe.
- Memoize only with evidence.
React.memo/useMemo/useCallbackafter profiling, not before. - Hand off. Brief decision log: what was picked, what was rejected, why.
Examples
- “Build a
<UserMenu>component with avatar, dropdown, and signout” → write it in TS strict with semantic HTML, accessible roles, focus management, anduseActionStatefor the signout — noany, no untyped event handlers. - “Where should the filter/sort/page state live in this table?” → in the URL via TanStack Router search params, not
useState. - “Convert this class component to hooks” → migrate, name effect cleanup explicitly, and call out any subtle behavior change (mount/unmount semantics, stale closure risk).
- “Should this be a Server Component or Client Component?” → ask one question (App Router? data needs? interactivity?) then pick — Server by default, Client when interactivity or browser-only APIs force it.
Self-rubric
- Typed end-to-end. No
any, no unjustifiedas.tsc --noEmitis clean. - State lives in the right place. Filter/sort/page in the URL, not
useState. - No needless memoization. Memo is a perf fix with evidence, not a default.
- Effects clean up. Every subscription, timer, listener has a return.
- Accessible by default. Semantic elements, labels, focus order, contrast.
- Tested at the level that matters. Integration covers the user path; units cover the gnarly logic.
- Validated:
scripts/check_frontend.sh [TARGET_REPO_PATH]exits 0.
Constraints (MUST / MUST NOT)
MUST: TypeScript strict mode · stable keys · effect cleanup · semantic HTML + ARIA · error boundaries in production · Suspense for async.
MUST NOT: mutate state directly · use array index as key for dynamic lists · create new functions/objects in JSX passed to memoized children · ignore strict-mode warnings.
Validate
scripts/check_frontend.sh [TARGET_REPO_PATH] — prefers oxlint (50-100x faster than eslint) with the bundled templates/oxlint/.oxlintrc.json config, which adds a custom JS plugin rule (no-unnecessary-react-import) on top of oxlint’s defaults plus typescript/no-explicit-any as a hard error; auto-enables --type-aware when oxlint-tsgolint is installed in the target repo (experimental upstream). Falls back to eslint if oxlint isn’t installed, and to grep-based any/React-import checks if neither is. Always runs tsc --noEmit, hard-fails when tsconfig.json lacks "strict": true, and warns (fuzzy heuristic) on a useEffect that looks like derived state synced via a single setState call.
Deep reference — Accessibility (a11y)
Semantic HTML
// BAD - Divs for everything<div onClick={handleClick}>Click me</div>
// GOOD - Semantic elements<button onClick={handleClick}>Click me</button><header>...</header><nav>...</nav><main>...</main><article>...</article><aside>...</aside><footer>...</footer>Keyboard navigation and focus trapping
function Modal({ isOpen, onClose, children }: ModalProps) { const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (isOpen) { const focusable = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); (focusable?.[0] as HTMLElement)?.focus();
const handleTab = (e: KeyboardEvent) => { if (e.key === 'Tab' && focusable) { const first = focusable[0] as HTMLElement; const last = focusable[focusable.length - 1] as HTMLElement; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleTab); return () => document.removeEventListener('keydown', handleTab); } }, [isOpen, onClose]);
if (!isOpen) return null;
return ( <div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title"> {children} </div> );}ARIA attributes
// Live regions for dynamic content<div aria-live="polite" aria-atomic="true"> {status && <p>{status}</p>}</div>
// Loading states<button disabled={isLoading} aria-busy={isLoading}> {isLoading ? 'Loading...' : 'Submit'}</button>
// Form labels<label htmlFor="email">Email address</label><input id="email" type="email" aria-required="true" aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && ( <p id="email-error" role="alert">{errors.email}</p>)}
// Navigation<nav aria-label="Main navigation"> <ul> <li><a href="/" aria-current={isHome ? 'page' : undefined}>Home</a></li> </ul></nav>
// Toggle buttons<button aria-pressed={isEnabled} onClick={() => setIsEnabled(!isEnabled)}> {isEnabled ? 'Enabled' : 'Disabled'}</button>
// Expandable sections<button aria-expanded={isOpen} aria-controls="content-panel" onClick={() => setIsOpen(!isOpen)}> Show details</button><div id="content-panel" hidden={!isOpen}>Content here</div>Screen-reader-only content
const srOnly = 'absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0';
<a href="#main-content" className={srOnly + ' focus:not-sr-only focus:absolute focus:top-0'}> Skip to main content</a>
<button aria-label="Close menu"> <XIcon aria-hidden="true" /></button>
<button> <XIcon aria-hidden="true" /> <span className={srOnly}>Close menu</span></button>Deep reference — Testing strategies
Component testing with Testing Library
import { render, screen, fireEvent } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { Button } from './Button';
describe('Button', () => { it('renders with correct text', () => { render(<Button>Click me</Button>); expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); });
it('calls onClick when clicked', async () => { const user = userEvent.setup(); const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click me</Button>); await user.click(screen.getByRole('button')); expect(handleClick).toHaveBeenCalledTimes(1); });
it('is disabled when loading', () => { render(<Button isLoading>Submit</Button>); expect(screen.getByRole('button')).toBeDisabled(); expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); });});Hook testing
import { renderHook, act } from '@testing-library/react';import { useCounter } from './useCounter';
describe('useCounter', () => { it('increments count', () => { const { result } = renderHook(() => useCounter()); act(() => result.current.increment()); expect(result.current.count).toBe(1); });});Integration testing
import { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { LoginForm } from './LoginForm';import { AuthProvider } from '@/contexts/AuthContext';
const mockLogin = jest.fn();jest.mock('@/lib/auth', () => ({ login: (...args: unknown[]) => mockLogin(...args) }));
describe('LoginForm', () => { beforeEach(() => mockLogin.mockReset());
it('submits form with valid credentials', async () => { const user = userEvent.setup(); mockLogin.mockResolvedValueOnce({ user: { id: '1', name: 'Test' } }); render(<AuthProvider><LoginForm /></AuthProvider>); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /sign in/i })); await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123'); }); });});E2E testing with Playwright
import { test, expect } from '@playwright/test';
test.describe('Checkout flow', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.click('[data-testid="product-1"] button'); await page.click('[data-testid="cart-button"]'); });
test('completes checkout with valid payment', async ({ page }) => { await page.click('text=Proceed to Checkout'); await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="address"]', '123 Test St'); await page.click('text=Place Order'); await expect(page).toHaveURL(/\/order\/confirmation/); });});Deep reference — TypeScript patterns
Component props and polymorphic components
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary'; isLoading?: boolean;}
function Button({ variant = 'primary', isLoading, children, ...props }: ButtonProps) { return ( <button {...props} disabled={props.disabled || isLoading} className={cn(variants[variant], props.className)}> {isLoading ? <Spinner /> : children} </button> );}
type PolymorphicProps<E extends React.ElementType> = { as?: E } & React.ComponentPropsWithoutRef<E>;
function Box<E extends React.ElementType = 'div'>({ as, children, ...props }: PolymorphicProps<E>) { const Component = as || 'div'; return <Component {...props}>{children}</Component>;}Discriminated unions for state machines
type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error };
function DataDisplay<T>({ state, render }: { state: AsyncState<T>; render: (data: T) => React.ReactNode }) { switch (state.status) { case 'idle': return null; case 'loading': return <Spinner />; case 'success': return <>{render(state.data)}</>; case 'error': return <ErrorMessage error={state.error} />; }}Generic components and type guards
interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string;}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return <ul>{items.map((item, i) => <li key={keyExtractor(item)}>{renderItem(item, i)}</li>)}</ul>;}
interface User { id: string; name: string; }interface Admin extends User { role: 'admin'; permissions: string[]; }
function isAdmin(user: User): user is Admin { return 'role' in user && (user as Admin).role === 'admin';}Deep reference — Tailwind CSS in React
Component variants with CVA
import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';
const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { primary: 'bg-primary text-primary-foreground hover:bg-surface-strong', secondary: 'bg-secondary text-secondary-foreground hover:bg-muted', ghost: 'hover:bg-secondary', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-12 px-6 text-base', icon: 'h-10 w-10' }, }, defaultVariants: { variant: 'primary', size: 'md' }, });
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
function Button({ className, variant, size, ...props }: ButtonProps) { return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;}Note: use semantic tokens (
bg-primary,text-destructive-foreground) per ADR-001/ADR-006 — never raw Tailwind palette classes likebg-blue-600. The upstream example this was adapted from used raw palette colors; this stack always substitutes semantic tokens.
Responsive design
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 lg:gap-8"> {products.map(product => <ProductCard key={product.id} product={product} />)}</div>
<nav className="hidden md:flex">Desktop nav</nav><button className="md:hidden">Mobile menu</button>Deep reference — Project structure and barrel exports
src/├── app/ # or src/routes for TanStack Router├── components/│ ├── ui/ # Shared UI components│ └── features/ # Feature-specific components├── hooks/ # Custom React hooks├── lib/ # Utilities and configs├── types/ # TypeScript types└── styles/// components/ui/index.ts — barrel exportexport { Button } from './Button';export { Input } from './Input';export { Card, CardHeader, CardContent, CardFooter } from './Card';Deep reference — Security
XSS prevention
React escapes content by default. When rendering HTML content, sanitize first:
import DOMPurify from 'dompurify';
function SafeHTML({ html }: { html: string }) { const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'], ALLOWED_ATTR: ['href'], }); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;}Input validation with Zod (see references/zod-schemas.md for the full pattern)
import { z } from 'zod';import { useForm } from 'react-hook-form';import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'),});
type FormData = z.infer<typeof schema>;
function RegisterForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema) }); return ( <form onSubmit={handleSubmit(onSubmit)}> <Input {...register('email')} error={errors.email?.message} /> <Button type="submit">Register</Button> </form> );}Secure API calls
Never include secrets in client code — proxy through a server route/endpoint that reads server-side env vars, never NEXT_PUBLIC_*/PUBLIC_*-prefixed secrets.
Deep reference — Composition patterns
Compound components
const SelectContext = createContext<SelectContextType | null>(null);
function Select({ children, value, onChange }: SelectProps) { return ( <SelectContext.Provider value={{ value, onChange }}> <div className="relative">{children}</div> </SelectContext.Provider> );}
function SelectTrigger({ children }: { children: React.ReactNode }) { const context = useContext(SelectContext); if (!context) throw new Error('SelectTrigger must be used within Select'); return <button className="flex items-center gap-2 px-4 py-2 border rounded">{children}</button>;}
Select.Trigger = SelectTrigger;Render props and HOCs
function MouseTracker({ render }: { render: (pos: { x: number; y: number }) => React.ReactNode }) { const [position, setPosition] = useState({ x: 0, y: 0 }); useEffect(() => { const handler = (e: MouseEvent) => setPosition({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); }, []); return <>{render(position)}</>;}
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) { return function AuthenticatedComponent(props: P) { const { user, isLoading } = useAuth(); if (isLoading) return <LoadingSpinner />; if (!user) return <Navigate to="/login" />; return <WrappedComponent {...props} />; };}Deep reference — Custom hooks
// useDebouncefunction useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue;}
// useLocalStoragefunction useLocalStorage<T>(key: string, initialValue: T) { const [storedValue, setStoredValue] = useState<T>(() => { if (typeof window === 'undefined') return initialValue; try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch { return initialValue; } }); const setValue = useCallback((value: T | ((val: T) => T)) => { const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); if (typeof window !== 'undefined') window.localStorage.setItem(key, JSON.stringify(valueToStore)); }, [key, storedValue]); return [storedValue, setValue] as const;}
// useMediaQueryfunction useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(false); useEffect(() => { const media = window.matchMedia(query); setMatches(media.matches); const listener = (e: MediaQueryListEvent) => setMatches(e.matches); media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [query]); return matches;}Deep reference — Zustand state management
import { create } from 'zustand';import { persist } from 'zustand/middleware';
interface AuthStore { user: User | null; token: string | null; login: (email: string, password: string) => Promise<void>; logout: () => void;}
const useAuthStore = create<AuthStore>()( persist( (set) => ({ user: null, token: null, login: async (email, password) => { const { user, token } = await authAPI.login(email, password); set({ user, token }); }, logout: () => set({ user: null, token: null }), }), { name: 'auth-storage' } ));Deep reference — Performance patterns (see references/react-perf.md for the full audit workflow)
// useMemo for expensive calculationsconst processedData = useMemo(() => { let result = data.filter(item => item.name.toLowerCase().includes(filterText.toLowerCase())); return [...result].sort((a, b) => (a[sortColumn] < b[sortColumn] ? -1 : 1));}, [data, sortColumn, filterText]);
// Virtualization for long listsimport { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) { const parentRef = useRef<HTMLDivElement>(null); const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => parentRef.current, estimateSize: () => 50, overscan: 5, }); return ( <div ref={parentRef} className="h-[400px] overflow-auto"> <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}> {virtualizer.getVirtualItems().map(row => ( <div key={row.key} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${row.size}px`, transform: `translateY(${row.start}px)` }}> {items[row.index].name} </div> ))} </div> </div> );}Deep reference — Error boundaries
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { this.props.onError?.(error, errorInfo); }
render() { if (this.state.hasError) { return this.props.fallback || <ErrorFallback error={this.state.error} />; } return this.props.children; }}Combine with Suspense: <ErrorBoundary fallback={<ErrorMessage />}><Suspense fallback={<Spinner />}><AsyncDataLoader /></Suspense></ErrorBoundary>.
Deep reference — Anti-patterns to refuse
// BAD - Creates new object every render<Component style={{ color: 'red' }} items={[1, 2, 3]} />// GOODconst style = { color: 'red' };<Component style={style} items={items} />
// BAD - Index keys break with reordering/filtering{items.map((item, index) => <Item key={index} data={item} />)}// GOOD{items.map(item => <Item key={item.id} data={item} />)}
// BAD - Prop drilling through many levels// GOOD - Context or Zustand for cross-cutting state
// BAD - Mutating state directlyitems.push(item); setItems(items);// GOODsetItems(prev => [...prev, item]);
// BAD - useEffect for derived stateuseEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]);// GOOD - compute during render, or useMemo for expensive calcsconst total = items.reduce((s, i) => s + i.price, 0);Deep reference — Rendering strategies for SSR/RSC frameworks (Next.js-derived, applies to any RSC-capable meta-framework)
Server Components render on the server and send HTML to the client — default for data-heavy, non-interactive content. Use 'use client' only when a component needs event handlers, state, effects, or browser APIs.
// Server Component (default) — runs on the server, no client bundle impactasync function ProductsPage() { const products = await db.products.findMany(); return <div className="grid grid-cols-3 gap-4">{products.map(p => <ProductCard key={p.id} product={p} />)}</div>;}
// Client Component — only where interactivity is required'use client';function AddToCartButton({ productId }: { productId: string }) { const [isAdding, setIsAdding] = useState(false); async function handleClick() { setIsAdding(true); await addToCart(productId); setIsAdding(false); } return <button onClick={handleClick} disabled={isAdding}>{isAdding ? 'Adding...' : 'Add to Cart'}</button>;}Streaming with Suspense
async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); return ( <div> <h1>{product.name}</h1> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={params.id} /> </Suspense> </div> );}Parallel data fetching
async function Dashboard() { const [user, stats, notifications] = await Promise.all([getUser(), getStats(), getNotifications()]); return <div><UserHeader user={user} /><StatsPanel stats={stats} /><NotificationList notifications={notifications} /></div>;}Image optimization checklist (framework-agnostic)
- Explicit
width/heightoraspect-ratioto prevent CLS. loading="lazy"below the fold; eager/priority only for the LCP image.- AVIF/WebP with a fallback; responsive
sizesfor art-directed breakpoints. - Skeleton placeholders while dimensions are known but bytes aren’t loaded.
Bundle optimization checklist
- Tree-shake imports:
import debounce from 'lodash/debounce', not the whole library. - Dynamic
import()/React.lazyfor routes and heavy widgets off the critical path. - Analyze with
rollup-plugin-visualizeror the framework’s bundle analyzer; budget every route.
Core Web Vitals quick reference
| Area | Optimization | Impact |
|---|---|---|
| Images | Priority/eager for the LCP image, lazy elsewhere | High |
| Fonts | System font stack only (ADR-003) — no web font load cost | N/A (already zero) |
| Code | Dynamic imports for heavy/below-fold components | High |
| Data | Parallel fetching with Promise.all | High |
| Render | Server Components / static generation by default | High |
| Cache | Explicit revalidate/staleTime — never accidental always-fresh | Medium |
References
scripts/check_frontend.sh— oxlint (falls back to eslint)/tsc wrapper + no-anygate + unnecessary-React-import gate + tsconfig strict-mode gate + derived-state-via-useEffect heuristic.- React docs (react.dev) — canonical.
- TanStack Query — server cache.
- Testing Library guiding principles.
- WCAG quick reference. </content>