8.3 KiB
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:
useStatein ui/useEffectin ui/useContextin 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
- Reusability: UI elements can be used anywhere
- Testability: Pure functions are easy to test
- Maintainability: Clear separation of concerns
- Performance: No unnecessary re-renders
- Type Safety: Each layer has clear contracts
Migration Guide
If you have existing code that violates these rules:
- Extract UI elements from app/ to ui/
- Move stateful logic from ui/ to components/
- Remove Next.js imports from components/ui (pass callbacks from pages)
- 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.