Skip to content
developing-frontend-apps logo

Frontend Application Development Best Practices

developing-frontend-apps

Frontend application development best practices. Use when building, modifying, or reviewing frontend applications, React components, UI components, client-side JavaScript/TypeScript, CSS/styling, single-page applications, or web application architecture.

SKILL.md

Full skill instructions

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. useState or useReducer for 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 useState per 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-visualizer or source-map-explorer to 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 startTransition for 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.memo and stable callback references with useCallback. Profile first — premature memoization adds complexity without measurable gain.
  • Lazy-load images with loading="lazy" and always set width/height attributes.

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 with aria-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 has role="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 in 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-size instead of directional properties — supports RTL layouts.
  • No magic numbers. Use tokens, em/rem, or calc(). Every value should have a reason.
  • Prefer gap. gap on 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 transform and opacity — they run on the compositor thread, avoiding layout/​paint. Use will-change sparingly 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>
    
  • View Transitions API. Animated transitions between DOM states or pages with document.startViewTransition(). Pair with view-transition-name CSS property to animate specific elements between states.

TypeScript for Frontend

  • Strict mode. Enable strict: true in tsconfig.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. Use unknown and 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 const over enum. 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 (replaces useFormState). 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 some useContext / 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 render that 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: 3 and 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/offline events. 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 dangerouslySetInnerHTML with user input. Sanitize with DOMPurify if you must render HTML.
  • CSP. Set Content-Security-Policy header. 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 httpOnly cookies, not localStorage. localStorage is readable by any script on the page.
  • Dependencies. Run npm audit regularly. Use npm audit --omit=dev for production deps. Automate with Dependabot or Renovate.
  • SRI. Add integrity attribute 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/​*"] in tsconfig.json and vite.config.ts:
    // vite.config.ts
    resolve: {
      alias: { '@': path.resolve(__dirname, 'src') }
    }
    
  • Env vars. .env files with VITE_ 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:url for 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

  1. npx eslint . — fix all warnings and errors
  2. npx tsc --noEmit — fix type errors
  3. npx vitest run — fix failing tests
  4. npx playwright test — fix E2E failures
  5. npx axe-core or jest-axe — fix accessibility violations
  6. npx vite build && npx vite-bundle-visualizer — verify bundle under 200KB gzipped
  7. Lighthouse CI — verify performance score ≥ 90, accessibility ≥ 95
  8. 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