9.7 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 datalayout.tsx- Root layoutsroute.tsx- API routes*PageClient.tsx- Client entry points that wire server data to client templates
Rules:
page.tsxdoes ONLY data fetching and passes raw data to client components*PageClient.tsxmanages 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
classNameorstyleprops - 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(inui/). - 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/orpages/.
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:
- Breaks Encapsulation: The consumer needs to know about the internal styling system (Tailwind, CSS).
- Increases Fragility: Changing the internal implementation (e.g., switching from Tailwind to CSS Modules) requires updating all consumers.
- 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 useclassName,style, or any prop that accepts raw styling values. They must only use the semantic APIs provided byui/.templates/: Same ascomponents/.
The 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:
- Class-based
- Immutable
- Deterministic
- No side effects
- No
Intl.*ortoLocale*
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 ViewModel Builder
const viewModel = {
rating: RatingDisplay.format(dto.rating),
ratingColor: RatingDisplay.getColor(dto.rating)
};
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 Display Objects?
- When formatting is reusable across multiple ViewModels
- When mapping codes to labels
- When presentation logic needs to be deterministic
Key Benefits
- Clear boundaries: Each layer has a single responsibility.
- Encapsulation: Implementation details (HTML, CSS, Tailwind) are hidden within the
ui/layer. - Semantic APIs: Components are easier to read and use because they speak the language of the domain, not CSS.
- Maintainability: Changes to styling or internal implementation don't ripple through the entire codebase.
- Testability: Pure functions at UI/Display layer.
- Type safety: Clear, restricted contracts between layers prevent "prop soup".