| name | developing-frontend-apps |
| description | 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. |
Frontend Application Development Best Practices
Component Architecture
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:
const [items, setItems] = useState<Item[]>([]);
const [filtered, setFiltered] = useState<Item[]>([]);
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
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 sparingly and remove after animation completes.
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:
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):
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
SEO Basics
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 errors
npx tsc --noEmit — fix type errors
npx vitest run — fix failing tests
npx playwright test — fix E2E failures
npx axe-core or jest-axe — fix accessibility violations
npx 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