Files
gridpilot.gg/docs/architecture/website/COMPONENT_ARCHITECTURE.md
2026-01-13 12:10:15 +01:00

8.3 KiB

Component Architecture

This document defines the strict separation of concerns for all UI code in the website layer.

The Three Layers

apps/website/
├── app/                    ← App Layer (Pages & Layouts)
├── components/            ← Component Layer (App Components)
├── ui/                    ← UI Layer (Pure Elements)
└── hooks/                 ← Shared Logic

1. App Layer (app/)

Purpose: Pages, layouts, and routing configuration.

Characteristics:

  • Only page.tsx, layout.tsx, route.tsx files
  • Can import from components/, ui/, hooks/
  • Can use Next.js features (redirect, cookies, headers)
  • Can use Server Components
  • NO raw HTML with styling
  • NO business logic
  • NO state management

Allowed:

// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <DashboardLayout>
      <DashboardHeader />
      <DashboardStats />
      <DashboardActions />
    </DashboardLayout>
  );
}

Forbidden:

// ❌ WRONG - Raw HTML in app/
export default function DashboardPage() {
  return (
    <div className="bg-white p-6">  {/* ❌ No raw HTML with styling */}
      <h1 className="text-2xl">Dashboard</h1>
      <button onClick={handleSubmit}>Click</button>  {/* ❌ No inline handlers */}
    </div>
  );
}

2. Component Layer (components/)

Purpose: App-level components that may contain state and business logic.

Characteristics:

  • Can be stateful (useState, useReducer)
  • Can have side effects (useEffect)
  • Can use hooks
  • Can contain business logic
  • Can import from ui/, hooks/
  • NO Next.js imports (navigation, routing)
  • NO raw HTML with styling (use ui/ elements)

Allowed:

// components/DashboardHeader.tsx
export function DashboardHeader() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <header>
      <Button onClick={() => setIsOpen(!isOpen)}>Toggle</Button>
      {isOpen && <Menu />}
    </header>
  );
}

Forbidden:

// ❌ WRONG - Next.js imports in components/
import { useRouter } from 'next/navigation';  // ❌

export function DashboardHeader() {
  const router = useRouter();  // ❌
  // ...
}

3. UI Layer (ui/)

Purpose: Pure, reusable, stateless UI elements.

Characteristics:

  • Stateless (no useState, useReducer)
  • No side effects (no useEffect)
  • Pure functions based on props
  • Maximum reusability
  • Framework-agnostic
  • NO state management
  • NO Next.js imports
  • NO business logic

Allowed:

// ui/Button.tsx
export function Button({ children, onClick, variant = 'primary' }) {
  const className = variant === 'primary' 
    ? 'bg-blue-500 text-white' 
    : 'bg-gray-200 text-black';
  
  return (
    <button className={className} onClick={onClick}>
      {children}
    </button>
  );
}

// ui/Card.tsx
export function Card({ children, className = '' }) {
  return (
    <div className={`bg-white rounded-lg shadow ${className}`}>
      {children}
    </div>
  );
}

Forbidden:

// ❌ WRONG - State in UI element
export function Button({ children }) {
  const [isLoading, setIsLoading] = useState(false);  // ❌
  
  return <button>{isLoading ? 'Loading...' : children}</button>;
}

// ❌ WRONG - Next.js imports
import { useRouter } from 'next/navigation';  // ❌

export function Link({ href, children }) {
  const router = useRouter();  // ❌
  // ...
}

4. Hooks Layer (hooks/)

Purpose: Shared stateful logic.

Characteristics:

  • Can use all hooks
  • Can contain business logic
  • Can be used by components and pages
  • NO JSX
  • NO UI rendering

Allowed:

// hooks/useDropdown.ts
export function useDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  
  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen(!isOpen),
  };
}

ESLint Enforcement

1. No Raw HTML in app/

{
  "files": ["app/**/*.tsx", "app/**/*.ts"],
  "rules": {
    "gridpilot-rules/no-raw-html-in-app": "error"
  }
}

Catches:

  • <div className="..."> in app/
  • <button style={{...}}> in app/
  • <span onClick={...}> in app/

2. UI Element Purity

{
  "files": ["ui/**/*.tsx", "ui/**/*.ts"],
  "rules": {
    "gridpilot-rules/ui-element-purity": "error"
  }
}

Catches:

  • useState in ui/
  • useEffect in ui/
  • useContext in ui/

3. No Next.js in UI/Components

{
  "files": ["ui/**/*", "components/**/*"],
  "rules": {
    "gridpilot-rules/no-nextjs-imports-in-ui": "error"
  }
}

Catches:

  • import { useRouter } from 'next/navigation'
  • import { redirect } from 'next/navigation'
  • import Link from 'next/link'

4. Component Classification (Suggestions)

{
  "files": ["components/**/*", "ui/**/*"],
  "rules": {
    "gridpilot-rules/component-classification": "warn"
  }
}

Suggests:

  • Move pure components to ui/
  • Move stateful elements to components/

Dependency Flow

app/ (pages)
  ↓ imports from
components/ (stateful)
  ↓ imports from
ui/ (pure)
  ↓ imports from
hooks/ (logic)

Never:

  • app/ → hooks/ (pages don't need hooks directly)
  • ui/ → components/ (ui must stay pure)
  • ui/ → next/navigation (ui must be framework-agnostic)
  • components/ → next/navigation (navigation should be passed from pages)

Examples

Correct Architecture

// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <DashboardLayout>
      <DashboardHeader />
      <DashboardStats />
    </DashboardLayout>
  );
}

// components/DashboardHeader.tsx
export function DashboardHeader() {
  const [search, setSearch] = useState('');
  
  return (
    <header>
      <SearchInput value={search} onChange={setSearch} />
      <Button onClick={handleSearch}>Search</Button>
    </header>
  );
}

// ui/SearchInput.tsx
export function SearchInput({ value, onChange }) {
  return (
    <input 
      value={value} 
      onChange={e => onChange(e.target.value)}
      className="border p-2"
    />
  );
}

// ui/Button.tsx
export function Button({ children, onClick }) {
  return (
    <button onClick={onClick} className="bg-blue-500 text-white">
      {children}
    </button>
  );
}

Wrong Architecture

// app/dashboard/page.tsx
export default function DashboardPage() {
  const [search, setSearch] = useState('');  // ❌ State in page
  
  return (
    <div className="p-6">  {/* ❌ Raw HTML */}
      <input 
        value={search}
        onChange={e => setSearch(e.target.value)}  // ❌ State logic
      />
      <button onClick={() => router.push('/search')}>  {/* ❌ Router in page */}
        Search
      </button>
    </div>
  );
}

Benefits

  1. Reusability: UI elements can be used anywhere
  2. Testability: Pure functions are easy to test
  3. Maintainability: Clear separation of concerns
  4. Performance: No unnecessary re-renders
  5. Type Safety: Each layer has clear contracts

Migration Guide

If you have existing code that violates these rules:

  1. Extract UI elements from app/ to ui/
  2. Move stateful logic from ui/ to components/
  3. Remove Next.js imports from components/ui (pass callbacks from pages)
  4. Use hooks/ for shared logic

Example migration:

// Before
// app/page.tsx
export default function Page() {
  return <div className="bg-white p-6">...</div>;
}

// After
// app/page.tsx
export default function Page() {
  return <HomePage />;
}

// components/HomePage.tsx
export function HomePage() {
  return (
    <PageContainer>
      <HomeContent />
    </PageContainer>
  );
}

// ui/PageContainer.tsx
export function PageContainer({ children }) {
  return <div className="bg-white p-6">{children}</div>;
}

Summary

Layer State Next.js HTML Purpose
app/ No Yes No Pages & layouts
components/ Yes No No App components
ui/ No No Yes Pure elements
hooks/ Yes No No Shared logic

Golden Rule: Each layer should only depend on layers below it.