Frontend Application Development Best Practices
Component Architecture
- One component, one job. If it fetches data AND renders a complex UI, split it.
- Composition over inheritance — use children, render props, and custom hooks.
- Presentational components receive data via props and emit events. Container components handle data fetching and state.
- Co-locate related files — component, styles, tests, types in the same directory.
- Define an explicit Props interface for every component:
interface UserCardProps { user: User; onEdit: (id: string) => void; variant?: 'compact' | 'full'; } function UserCard({ user, onEdit, variant = 'full' }: UserCardProps) { // ... } - Avoid prop drilling beyond 2 levels — use context or composition (pass components, not data).
- Keep components under ~200 lines. Extract hooks when logic dominates the render.
State Management
- Local state first.
useStateoruseReducerfor component-scoped concerns. - Lift state only when siblings share it. Move to the nearest common ancestor, no higher.
- Server state is not app state. Use a data-fetching library (TanStack Query, SWR) — it handles caching, refetching, optimistic updates.
- Minimal global state. Reserve for truly app-wide concerns: auth, theme, locale.
- Derive, don't duplicate. Compute values from source state:
// Bad: syncing filtered list into separate state const [items, setItems] = useState<Item[]>([]); const [filtered, setFiltered] = useState<Item[]>([]); // Good: derive from source const filtered = useMemo( () => items.filter(i => i.status === activeFilter), [items, activeFilter] ); - Global state libraries (when context isn't enough):
- Zustand — minimal API, great for simple global state (auth, UI toggles). No boilerplate.
- Jotai — atomic state, bottom-up approach. Good for independent pieces of state that compose.
- Redux Toolkit — full-featured, middleware, devtools. Use for complex state with many interdependent slices.
- Pick Zustand by default. Reach for Redux Toolkit only when you need middleware, time-travel debugging, or complex normalized state.
- Forms: use React Hook Form or TanStack Form for multi-field forms with validation. Manual
useStateper field doesn't scale past 3-4 fields — validation, dirty tracking, and error display become unwieldy. - Immutable updates. Spread or
structuredClone— never mutate state directly. - URL as state. Search params, filters, pagination belong in the URL for shareability and back-button support.
Performance
Bundle size
- Tree-shake — use ES modules, avoid barrel files that pull entire libraries.
- Code-split at route boundaries with
lazy(). Lazy-load heavy components (editors, charts, maps). - Run
npx vite-bundle-visualizerorsource-map-explorerto find bloat. - Target: initial JS payload under 200KB gzipped.
Core Web Vitals
- LCP — preload hero image, inline critical CSS, avoid render-blocking scripts.
- INP — keep main thread free, defer non-critical work, use
startTransitionfor expensive updates. - CLS — set explicit dimensions on images/video, reserve space for dynamic content, avoid layout shifts from web fonts.
Rendering
- Virtualize long lists (TanStack Virtual, react-window) — never render 1000+ DOM nodes.
- Memoize expensive components with
React.memoand stable callback references withuseCallback. Profile first — premature memoization adds complexity without measurable gain. - Lazy-load images with
loading="lazy"and always setwidth/heightattributes.
Accessibility
- Semantic HTML. Use
<nav>,<main>,<aside>,<section>,<header>,<footer>. Native interactive elements over styled divs. - Keyboard operable. Every interactive element reachable via Tab. Custom widgets need arrow key navigation.
- Alt text. Descriptive for informational images. Empty
alt=""for decorative images. - Form labels. Every input needs a
<label>. Link error messages witharia-describedby:<label htmlFor="email">Email</label> <input id="email" aria-describedby="email-error" aria-invalid={!!error} /> {error && <span id="email-error" role="alert">{error}</span>} - ARIA only when HTML falls short. A
<button>already hasrole="button"— don't add it again. - Color contrast. 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+).
- Focus management. Trap focus in modals. Restore focus to the trigger element on close.
- Test with a screen reader. VoiceOver (macOS), NVDA (Windows). Run
@axe-core/cliorvitest-axein CI.
CSS Architecture
- Scoped styles. CSS Modules (
.module.css) or Tailwind utility classes. Avoid global stylesheets beyond reset/tokens. - Design tokens. Define colors, spacing, typography as CSS custom properties on
:root::root { --color-primary: oklch(55% 0.25 260); --space-sm: 0.5rem; --space-md: 1rem; --radius-md: 0.5rem; --font-body: system-ui, sans-serif; } - Mobile-first. Base styles for small screens,
@media (min-width: ...)for larger. - Logical properties.
margin-inline,padding-block,inline-sizeinstead of directional properties — supports RTL layouts. - No magic numbers. Use tokens,
em/rem, orcalc(). Every value should have a reason. - Prefer gap.
gapon flex/grid replaces margin hacks and adjacent sibling selectors. - Respect motion preferences. Wrap animations in
@media (prefers-reduced-motion: no-preference). Provide a static alternative for users with vestibular disorders.@media (prefers-reduced-motion: no-preference) { .card { transition: transform 0.2s ease; } .card:hover { transform: scale(1.02); } } - Performant animations. Animate only
transformandopacity— they run on the compositor thread, avoiding layout/paint. Usewill-changesparingly and remove after animation completes. - CSS Nesting. Native nesting without preprocessors. Nest related selectors to co-locate styles:
.card { padding: var(--space-md); & .title { font-weight: 700; } &:hover { box-shadow: 0 2px 8px oklch(0% 0 0 / 0.1); } @media (min-width: 768px) { padding: var(--space-lg); } } - Container Queries. Size components based on their container, not the viewport — essential for reusable components:
.card-container { container-type: inline-size; } @container (min-width: 400px) { .card { grid-template-columns: 1fr 2fr; } } - Popover API. Declarative popovers without JavaScript — handles dismiss-on-outside-click, top-layer stacking, and focus management:
<button popovertarget="menu">Open</button> <div id="menu" popover>Popover content</div> @starting-style. Entry animations for elements transitioning fromdisplay: noneor entering the DOM:dialog[open] { opacity: 1; transition: opacity 0.3s; @starting-style { opacity: 0; } }- View Transitions API. Animated transitions between DOM states or pages with
document.startViewTransition(). Pair withview-transition-nameCSS property to animate specific elements between states.
TypeScript for Frontend
- Strict mode. Enable
strict: trueintsconfig.json. No exceptions. - Props and state interfaces. Define them explicitly — never inline complex types:
interface SearchState { query: string; results: SearchResult[]; status: 'idle' | 'loading' | 'error' | 'success'; } - Avoid
any. Useunknownand narrow with type guards:function isApiError(err: unknown): err is ApiError { return typeof err === 'object' && err !== null && 'code' in err; } - API response types. Generate from OpenAPI spec (
openapi-typescript) or validate at the boundary with Zod. Never trust runtime data matches your types. - Discriminated unions for state machines:
type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T }; as constoverenum. Enums emit runtime code and have quirky behavior:const ROLES = ['admin', 'editor', 'viewer'] as const; type Role = (typeof ROLES)[number]; // 'admin' | 'editor' | 'viewer'
React 19
React 19 is the current stable release. Key additions:
New hooks:
useActionState(action, initialState)— manages async form action state (replacesuseFormState). Returns[state, formAction, isPending].useFormStatus()— in a child of<form>, reads{ pending, data, method, action }from the parent form. No prop drilling for loading state.useOptimistic(state, updateFn)— show optimistic UI immediately while an async action is pending. Reverts on error.use(promise | context)— read context or suspend on a promise inside render. Replaces someuseContext/ async data patterns.
function AddToCart({ productId }: { productId: string }) {
const [state, formAction, isPending] = useActionState(addToCartAction, null);
const [optimisticCart, addOptimistic] = useOptimistic(
cart,
(current, newItem: CartItem) => [...current, newItem],
);
return (
<form action={async (formData) => {
addOptimistic({ id: productId });
await formAction(formData);
}}>
<button disabled={isPending}>Add to cart</button>
{state?.error && <span role="alert">{state.error}</span>}
</form>
);
}
Ref as prop (no more forwardRef):
// React 19 — ref is a regular prop
function Input({ ref, ...props }: React.ComponentProps<'input'>) {
return <input ref={ref} {...props} />;
}
Document metadata — render <title>, <meta>, and <link> anywhere in the tree; React hoists them to <head>:
function ProductPage({ product }: { product: Product }) {
return (
<>
<title>{product.name} | Shop</title>
<meta name="description" content={product.description} />
<h1>{product.name}</h1>
</>
);
}
React Compiler — automatically memoizes components and callbacks. When enabled, manual useMemo, useCallback, and React.memo wrappers become largely unnecessary. Profile before adding manual memoization — the compiler may already handle it.
Server Components (RSC): in frameworks like Next.js App Router, components run on the server by default — no client JS, no hydration, direct DB/file access. Use "use client" to mark the client boundary. Server Actions ("use server" async functions) handle mutations from Server Components without a separate API layer.
Testing
Unit tests
- Test behavior, not implementation. Interact like a user — click, type, assert visible output.
- Query by role, label, text — not by class name or test ID (last resort).
- Mock external dependencies (API, router, storage), not internal modules.
Integration tests
- Render full pages with mocked API (MSW). Test routing between pages, multi-step form flows, error states.
- Use a custom
renderthat wraps providers (router, query client, theme).
E2E tests
- Cover critical user paths: sign up, core workflow, payment. Keep the suite small (<50 tests) and fast (<5 minutes).
- Use Playwright. Page Object Model for reusable selectors.
- Run in CI against a staging environment or docker-compose stack.
Error Recovery
- Error boundaries. Wrap route segments with error boundaries. Show a fallback UI with a retry button — don't crash the entire page.
- Retry on failure. Configure TanStack Query with
retry: 3and exponential backoff. Show a manual retry button after automatic retries are exhausted.const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), }, }, }); - Error pages. Dedicated 404 and 500 components. If using a framework with file-based routing, use its conventions (
not-found.tsx,error.tsx). - Offline detection. Listen to
online/offlineevents. Show a banner when offline. Warn users before actions that require network. - Graceful degradation. If a non-critical feature fails (analytics, chat widget, recommendations), catch the error and hide the feature. Don't crash the page for optional UI.
Security
- XSS. Never use
dangerouslySetInnerHTMLwith user input. Sanitize with DOMPurify if you must render HTML. - CSP. Set
Content-Security-Policyheader. At minimum:default-src 'self'; script-src 'self'. - CORS. Configure on the server, not the client. Never use
Access-Control-Allow-Origin: *with credentials. - Tokens. Store in
httpOnlycookies, notlocalStorage.localStorageis readable by any script on the page. - Dependencies. Run
npm auditregularly. Usenpm audit --omit=devfor production deps. Automate with Dependabot or Renovate. - SRI. Add
integrityattribute to CDN<script>and<link>tags. - Error monitoring. Use Sentry or Datadog RUM to capture client-side errors in production. Configure source maps for readable stack traces.
Build Tooling
- Vite for new projects. Fast dev server (native ESM), optimized production builds (Rollup).
- Biome as an alternative to ESLint + Prettier. Single Rust-based tool for linting and formatting — faster, zero config for most projects. Evaluate for new projects; existing ESLint configs with custom rules may not have Biome equivalents yet.
- Path aliases.
"@/*": ["./src/*"]intsconfig.jsonandvite.config.ts:// vite.config.ts resolve: { alias: { '@': path.resolve(__dirname, 'src') } } - Env vars.
.envfiles withVITE_prefix for client-exposed variables. Never expose secrets —VITE_vars are embedded in the bundle. - Hashed filenames. Vite does this by default — enables aggressive caching.
- Source maps. Enable in production for error tracking (Sentry, Datadog). Upload maps privately, don't serve them publicly.
- CI pipeline:
lint→type-check→test→build→lighthouse
SEO Basics
- SSR/SSG for content that needs indexing. SPAs with client-side rendering are invisible to most crawlers.
- Unique
<title>and<meta name="description">per page. Title under 60 chars, description under 155. - Open Graph.
og:title,og:description,og:image,og:urlfor social previews. - Canonical URL.
<link rel="canonical" href="...">to avoid duplicate content. - JSON-LD. Structured data for rich results:
<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Article", "headline": "..." } </script> robots.txt+ sitemap.xml. Sitemap lists all indexable URLs. Submit to Google Search Console.
New frontend app workflow
- [ ] Scaffold with Vite (React + TypeScript template)
- [ ] Configure strict tsconfig, path aliases, ESLint, Prettier
- [ ] Set up CSS strategy (CSS Modules or Tailwind)
- [ ] Define design tokens (CSS custom properties)
- [ ] Set up routing (React Router, TanStack Router)
- [ ] Configure data fetching (TanStack Query)
- [ ] Add testing stack (Vitest + Testing Library + MSW + Playwright)
- [ ] Add error boundary at app root and error pages (404, 500)
- [ ] Set up CI pipeline (lint → type-check → test → build → lighthouse)
- [ ] Configure env vars, source maps, bundle analysis
- [ ] Run validation loop (below)
Validation loop
npx eslint .— fix all warnings and errorsnpx tsc --noEmit— fix type errorsnpx vitest run— fix failing testsnpx playwright test— fix E2E failuresnpx @axe-core/cliorvitest-axein tests — fix accessibility violationsnpx vite build && npx vite-bundle-visualizer— verify bundle under 200KB gzipped- Lighthouse CI — verify performance score ≥ 90, accessibility ≥ 95
- Repeat until all checks pass clean
Deep-dive references
Component patterns: See patterns/component-patterns.md for directory structure, composition, forms, error boundaries, compound components Performance patterns: See patterns/performance-patterns.md for profiling, code splitting, images, fonts, caching, rendering optimization Testing patterns: See patterns/testing-patterns.md for Vitest setup, component tests, MSW mocking, Playwright E2E Accessibility: See accessibility-cheatsheet.md for WCAG checklist, semantic HTML, ARIA reference, keyboard patterns
Official references
- WCAG 2.2 — Web Content Accessibility Guidelines, Level AA target
- web.dev — Core Web Vitals, performance, best practices
- Testing Library — query priorities, best practices, framework integrations
- Vite — configuration, plugins, build optimization