Skip to content

TypeScript Coding Guidelines (React Frontend)

The primary, thorough guideline for the frontend-react profile. Builds on three other guidelines:

Project Layering

This profile follows the app → infra → usecase → domain dependency direction (see general_coding.md), under src/:

  • domain/ — pure business entities and value objects. No React import, no fetch, no browser API. Exactly the "Domain Model" of ui_architecture.md.
  • usecase/ — application logic operating on the domain, plus port interfaces (e.g. an AuthProvider port — per general_coding.md, output-port interfaces belong in usecase, not domain) and the Presentation-Model/View-Model construction logic for a screen.
  • infra/ — concrete port implementations: the real API client and its DTO-to-domain mapping (see below), NoopAuthProvider (and any later real auth adapter), and MSW mock handlers.
  • app/ — the View layer: components, pages, routing, and the composition root that wires infra implementations into usecases.

Applying MVVM with React Hooks

ui_architecture.md describes Presentation Model (PM) and View Model (VM) as "plain objects," independent of any UI framework. In React, the natural equivalent of a plain, framework-agnostic object with reactive properties is a custom hook:

  • A PM hook (e.g. useUserProfilePresentationModel(user: User)) takes domain data and returns display-ready values — formatted strings, filtered/sorted lists — with zero widget-state fields.
  • A VM hook (e.g. useLoginFormViewModel(pm: LoginPresentationModel)) returns only widget control state: isSubmitEnabled, isSpinnerVisible, hasEmailError — booleans and enums, never data.
  • The View is a plain function component that calls these hooks and renders their return values — zero branching logic in the component body beyond what JSX itself requires for conditional rendering driven by VM flags.
  • PM/VM hooks are tested with @testing-library/react-hooks-style rendering (or plain function calls, if the hook has no internal useState/useEffect) — no full component render needed.
// usecase/profile/useUserProfilePresentationModel.ts — Presentation Model
export function useUserProfilePresentationModel(user: User) {
  return {
    displayName: user.fullName.toString(),
    joinedLabel: formatRelativeDate(user.joinedAt),
  };
}

// app/profile/UserProfileView.tsx — View: no logic beyond binding
export function UserProfileView({ user }: { user: User }) {
  const pm = useUserProfilePresentationModel(user);
  return (
    <section data-testid="user-profile">
      <h1 data-testid="user-profile-name">{pm.displayName}</h1>
      <p data-testid="user-profile-joined">{pm.joinedLabel}</p>
    </section>
  );
}

DTO-to-Domain Mapping

The API is an external boundary like any other. This mirrors the infra→usecase boundary pattern in error_handling.md and the DDD alignment in data_modeling.md. infra/ receives raw DTOs matching the API/OpenAPI contract and maps them into domain/ entities/value objects before usecase/ sees them.

  • Define DTO types next to the API client in infra/api/ — they mirror the wire format exactly, including fields the domain doesn't need.
  • Write one mapping function per DTO (toUser(dto: UserDto): User), even when a field is a pure passthrough — the mapping seam is what absorbs a future API contract change without rippling into domain logic or views.
  • Never pass a DTO type into usecase/ or domain/ — if a function signature in those layers accepts a *Dto type, the mapping boundary has been skipped.

React Hooks Rules

  • Never call a hook conditionally, in a loop, or after an early return — hook call order must be identical on every render.
  • Declare every reactive value a useEffect/useMemo/useCallback reads in its dependency array; do not suppress the linter rule instead of fixing the dependency.
  • One responsibility per custom hook; a hook that returns both PM-shaped and VM-shaped values should be split into two.

Stable Test Hooks

  • Every interactive element and every piece of content a test might assert on gets a data-testid, named <feature>-<element> in kebab-case (e.g. login-submit-button, user-profile-name) — see the example above.
  • Never target elements in tests by CSS class, tag name, or DOM position — see Responsive Web Guidelines.

Testing with Mock Service Worker (MSW)

  • Define MSW request handlers once in infra/api/mocks/handlers.ts; reuse them in component tests (Vitest + React Testing Library), in local dev mode (infra/api/mocks/browser.ts), and in mocked E2E runs (infra/api/mocks/server.ts for Node/Playwright) — one source of truth for fake API responses, not three.
  • Component tests assert on rendered output and data-testid queries, never on internal hook state.

Error Handling

  • A custom Error subclass exposes its data as readonly constructor properties (e.g. readonly bookingId: string), not only inside .message — see error_handling.md and js_coding.md for the cross-language rationale.

Directives

  • Follow src/{domain,usecase,infra,app} layering
  • Presentation Model and View Model are custom hooks with zero UI framework leakage; View components bind only, no branching logic
  • Map API DTOs to domain entities/value objects at the infra→usecase boundary — never pass a DTO type into usecase or domain
  • Never call hooks conditionally or in a loop; declare every effect/memo dependency explicitly
  • Every test-targeted element has a data-testid in <feature>-<element> kebab-case form
  • Define MSW handlers once in infra/api/mocks/, reused across component tests, dev mode, and mocked E2E
  • Custom Error subclasses expose their data as readonly constructor properties, not only inside .message