Files
gridpilot.gg/docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md
2026-01-24 01:22:43 +01:00

10 KiB

React Component Architecture (Concept)

This document defines the clean concept for React component architecture in apps/website.

Core Principle

Separation of concerns by responsibility, not just by file location.

The Four Layers

1. App Layer (app/)

Purpose: Entry points and data orchestration

What lives here:

  • page.tsx - Server Components that fetch data
  • layout.tsx - Root layouts
  • route.tsx - API routes
  • *PageClient.tsx - Client entry points that wire server data to client templates

Rules:

  • page.tsx does ONLY data fetching and passes raw data to client components
  • *PageClient.tsx manages client state and event handlers
  • No UI rendering logic (except loading/error states)

Example:

// app/teams/page.tsx
export default async function TeamsPage() {
  const query = new TeamsPageQuery();
  const result = await query.execute();
  
  if (result.isErr()) {
    return <ErrorTeams />;
  }
  
  return <TeamsPageClient teams={result.value.teams} />;
}

// app/teams/TeamsPageClient.tsx
'use client';
export function TeamsPageClient({ teams }: TeamsViewData) {
  const [searchQuery, setSearchQuery] = useState('');
  const router = useRouter();
  
  const handleTeamClick = (teamId: string) => {
    router.push(`/teams/${teamId}`);
  };
  
  return (
    <TeamsTemplate
      teams={teams}
      searchQuery={searchQuery}
      onSearchChange={setSearchQuery}
      onTeamClick={handleTeamClick}
    />
  );
}

2. Template Layer (templates/)

Purpose: Composition and layout of components

What lives here:

  • Stateless component compositions
  • Page-level layouts
  • Component orchestration

Rules:

  • Templates ARE stateless (no useState, useEffect)
  • Templates CAN use 'use client' for event handling and composition
  • Templates receive ViewData (primitives) and event handlers
  • Templates compose components and UI elements
  • No business logic
  • No data fetching

Example:

// templates/TeamsTemplate.tsx
'use client';

export function TeamsTemplate({ teams, searchQuery, onSearchChange, onTeamClick }: TeamsTemplateProps) {
  return (
    <main>
      <Header>
        <SearchInput value={searchQuery} onChange={onSearchChange} />
      </Header>
      
      <TeamLeaderboardPreview
        teams={teams}
        onTeamClick={onTeamClick}
      />
      
      <TeamGrid teams={teams} onTeamClick={onTeamClick} />
    </main>
  );
}

3. Component Layer (components/)

Purpose: Reusable app-specific components

What lives here:

  • Components that understand app concepts (teams, races, leagues)
  • Components that may contain state and business logic
  • Components that orchestrate UI elements for app purposes

Rules:

  • Can be stateful
  • Can contain app-specific business logic
  • Can use Next.js hooks (but not Next.js components like Link)
  • Should be reusable within the app context
  • Strictly Forbidden: Generic UI primitives (Box, Surface) and generic wrappers (Layout, Container)
  • Strictly Forbidden: Raw HTML tags (use ui/ components instead)
  • Strictly Forbidden: The className or style props
  • Strictly Forbidden: Passing implementation details (Tailwind classes, CSS values) as props to UI components

Example:

// components/teams/TeamLeaderboardPreview.tsx
'use client';

import { Card, CardHeader, TeamRow } from '@/ui';

export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) {
  // App-specific logic: medal colors, ranking, etc.
  const getMedalColor = (position: number) => {
    if (position === 0) return 'gold';
    if (position === 1) return 'silver';
    return 'none';
  };
  
  return (
    <Card variant="elevated">
      <CardHeader title="Top Teams" />
      {teams.map((team, index) => (
        <TeamRow
          key={team.id}
          name={team.name}
          rank={index + 1}
          onClick={() => onTeamClick(team.id)}
          achievement={getMedalColor(index)}
        />
      ))}
    </Card>
  );
}

4. UI Layer (ui/)

Purpose: Generic, reusable UI primitives

What lives here:

  • Pure UI elements with no app knowledge
  • Generic building blocks
  • Framework-agnostic components

Rules:

  • Stateless (no useState, useEffect)
  • No app-specific logic
  • No Next.js imports
  • Only receive props and render
  • Maximum reusability
  • Encapsulation: Must NOT expose className, style, or Tailwind-specific props to consumers.
  • Semantic APIs: Use semantic props (e.g., variant="primary", size="large") instead of implementation details.

Example:

// ui/Button.tsx
// GOOD: Semantic API
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
  const classes = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  }[variant];

  return (
    <button
      className={`px-4 py-2 rounded-md transition-colors ${classes}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// BAD: Exposing implementation details
// export function Button({ className, backgroundColor, padding }: Props) { ... }

UI Layering & Primitives

To prevent "div wrapper" abuse and maintain architectural integrity, we enforce a strict boundary between Primitives and Semantic UI.

Semantic UI & Layout (Allowed in Components)

  • Building blocks: Card, Panel, Button, Table.
  • Layout Components: Layout, Container (in ui/).
  • Restricted flexibility: Semantic components are restricted to their defined props. They do NOT allow arbitrary styling props (bg, border, etc.).
  • Public API: These are the only UI elements that should be imported by components/ or pages/.

Rule of thumb: If you need a styled container or layout in a component, use Panel, Card, Layout, or Container. If you need a new type of semantic layout, create it in ui/ using primitives. Direct use of primitives (Box, Surface, Stack, Grid) or raw HTML tags in components/ is forbidden.

Clean Component APIs (Anti-Pattern: Prop Pollution)

We strictly forbid "Prop Pollution" where implementation details leak into component APIs. This is unmaintainable and unreadable.

The Problem

When a component exposes props like className, style, mt={4}, or bg="blue-500", it:

  1. Breaks Encapsulation: The consumer needs to know about the internal styling system (Tailwind, CSS).
  2. Increases Fragility: Changing the internal implementation (e.g., switching from Tailwind to CSS Modules) requires updating all consumers.
  3. Reduces Readability: Component usage becomes cluttered with styling logic instead of business intent.

The Solution: Semantic Props

Components must only expose props that describe what the component is or how it should behave semantically, not how it should look in terms of CSS.

Bad Prop (Implementation) Good Prop (Semantic)
className="mt-4 flex items-center" spacing="large" or layout="horizontal"
color="#FF0000" or color="red-500" intent="danger" or variant="error"
style={{ fontWeight: 'bold' }} emphasis="high"
isFullWidth={true} size="full"

Enforcement

  • ui/ components: May use Tailwind/CSS internally but MUST NOT expose these details in their props.
  • components/ components: MUST NOT use className, style, or any prop that accepts raw styling values. They must only use the semantic APIs provided by ui/.
  • templates/: Same as components/.

The Formatter & Display Object Layer

Purpose: Reusable formatting and presentation logic

What lives here:

  • lib/display-objects/
  • Deterministic formatting functions
  • Code-to-label mappings
  • Value transformations for display

Rules:

  • Formatters: Stateless utilities for server-side primitive output.
  • Display Objects: Rich Value Objects for client-side interactive APIs.
  • Class-based
  • Immutable
  • Deterministic
  • No side effects
  • No Intl.* or toLocale* (unless client-only)

Usage:

// lib/display-objects/RatingDisplay.ts
export class RatingDisplay {
  static format(rating: number): string {
    return rating.toFixed(0);
  }
  
  static getColor(rating: number): string {
    if (rating >= 90) return 'text-green';
    if (rating >= 70) return 'text-yellow';
    return 'text-red';
  }
}

// In ViewData Builder (Server)
const viewData = {
  rating: RatingDisplay.format(dto.rating), // Primitive string
};

// In ViewModel (Client)
get rating() {
  return new RatingDisplay(this.data.rating); // Rich API
}

Dependency Flow

app/ (page.tsx)
  ↓ fetches data
app/ (*PageClient.tsx)
  ↓ manages state, creates handlers
templates/ (composition)
  ↓ uses components
components/ (app-specific)
  ↓ uses UI elements
ui/ (generic primitives)

Decision Rules

When to use *PageClient.tsx?

  • When you need client-side state management
  • When you need event handlers that use router/other hooks
  • When server component needs to be split from client template

When to use Template vs Component?

  • Template: Page-level composition, orchestrates multiple components
  • Component: Reusable app-specific element with logic

When to use Component vs UI?

  • Component: Understands app concepts (teams, races, leagues)
  • UI: Generic building blocks (button, card, input)

When to use Formatters/Display Objects?

  • When formatting is reusable across multiple ViewModels
  • When mapping codes to labels
  • When presentation logic needs to be deterministic

Key Benefits

  1. Clear boundaries: Each layer has a single responsibility.
  2. Encapsulation: Implementation details (HTML, CSS, Tailwind) are hidden within the ui/ layer.
  3. Semantic APIs: Components are easier to read and use because they speak the language of the domain, not CSS.
  4. Maintainability: Changes to styling or internal implementation don't ripple through the entire codebase.
  5. Testability: Pure functions at UI/Display layer.
  6. Type safety: Clear, restricted contracts between layers prevent "prop soup".