Skip to content

Architectural Principles (Non-Negotiable)

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/expert-react-frontend-engineer/architectural-principles.md
DescriptionNot specified

Source Content

Architectural Principles (Non-Negotiable)

These are the engineering values that guide every decision. When in doubt, refer back to these.

URL Is the Source of Truth for UI State

Every meaningful state change must be reflected in the URL.

This is not optional. It ensures full browser back/forward navigation, sharable deep links, state that survives page refresh, and trivial debugging.

What goes in the URL:

  • Active tab, panel, or view (?tab=settings)
  • Search/filter/sort parameters (?q=react&sort=date&page=3)
  • Modal/dialog open state (?modal=confirm-delete&id=123)
  • Selected items or active entity (/users/42/edit)
  • Pagination state (?page=2&limit=25)
  • Expanded/collapsed sections when meaningful

What does NOT go in the URL:

  • Ephemeral hover/focus states, animation states, tooltip visibility
  • Form field values mid-typing
  • Dropdown open/closed (unless it represents a meaningful view change)

Implementation pattern — TanStack Router:

import { useNavigate, useSearch } from '@tanstack/react-router'
import { userFiltersSchema } from './user.schema'
function UserList() {
const search = useSearch({ from: '/users' })
const navigate = useNavigate({ from: '/users' })
// search params are already validated by the route definition
const { tab, q, page, sort } = search
const handleTabChange = (newTab: string) => {
navigate({
search: (prev) => ({ ...prev, tab: newTab, page: 1 }),
})
}
}
// Route definition — validate search params with Zod
export const Route = createFileRoute('/users')({
validateSearch: (search) => userFiltersSchema.parse(search),
})

Decision rule: Before using useState, ask: “Would a user want to bookmark this state or hit Back to undo it?” If yes → URL. If no → local state or Zustand.

Dumb Components by Default

Components should be presentation-first. They receive data and callbacks via props. They do not fetch, mutate, or manage server state.

The component hierarchy:

Route/Page Component (smart — orchestrates data + URL state)
└── Feature Container (optional — composes dumb components, may use hooks)
└── Presentational Component (dumb — props in, JSX out)
└── Primitive UI Component (dumb — button, input, card, etc.)

Dumb component rules:

  • Accept all data via props (no internal fetching)
  • Accept all event handlers via callback props
  • Have zero side effects (no useEffect for data fetching)
  • Pure functions of props — same props = same output
  • Trivially unit-testable and trivially Storyable
  • Work in Storybook with mock props alone — no live API, no mandatory context

Smart component rules (route-level only):

  • Live at the route/page level
  • Own data fetching via TanStack Query hooks
  • Own URL state via TanStack Router search params / route params
  • Own global client state via Zustand selectors
  • Pass everything down as props to dumb components
// GOOD — Dumb component
interface UserCardProps {
user: User
onEdit: (id: string) => void
onDelete: (id: string) => void
isDeleting?: boolean
}
function UserCard({ user, onEdit, onDelete, isDeleting }: UserCardProps) {
return (
<article aria-label={`User ${user.name}`}>
<h3 className="ui-type-title-sm">{user.name}</h3>
<p className="ui-type-body-sm">{user.email}</p>
<div role="group" aria-label="Actions">
<Button onClick={() => onEdit(user.id)} variant="ghost">Edit</Button>
<Button
onClick={() => onDelete(user.id)}
disabled={isDeleting}
aria-busy={isDeleting}
variant="destructive"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</Button>
</div>
</article>
)
}
// BAD — Component fetches its own data
function UserCard({ userId }: { userId: string }) {
const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: ... })
// Untestable without mocking the entire query client
}

Reusable by Default — Design System First

Before creating any new component, audit the design system.

  1. Check dmwd/* Storybook tools for existing primitives and patterns
  2. Search src/components/ui/ (Layer 2) and src/components/patterns/ (Layer 3)
  3. Check the auto-generated catalog: pnpm run catalog
  4. If a close match exists, extend via CVA variants, composition, or slots — don’t duplicate
  5. Only create a new component if nothing suitable exists

Reusability checklist:

  • No hardcoded strings — use props or children
  • No hardcoded colors/sizes — use design tokens or CVA variant props
  • No business logic — only presentation logic
  • Accepts className prop for style overrides via cn()
  • Uses composition (children, slots) over configuration when possible
  • Storybook-compatible without requiring app-level providers, router context, or live API data

CVA + cn() for Variant Design

All visual variants use class-variance-authority (CVA). Class merging uses cn() (clsx + tailwind-merge).

import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
// base classes
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
outline: 'border border-input bg-background hover:bg-accent',
},
size: {
sm: 'h-8 px-3 ui-type-body-xs',
md: 'h-10 px-4 ui-type-body-sm',
lg: 'h-11 px-6 ui-type-body',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
}
function Button({ className, variant, size, leadingIcon, trailingIcon, children, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size }), className)} {...props}>
{leadingIcon && <span aria-hidden="true">{leadingIcon}</span>}
{children}
{trailingIcon && <span aria-hidden="true">{trailingIcon}</span>}
</button>
)
}

Rules:

  • CVA always. No inline conditional class strings that grow unwieldy.
  • Public variant props use semantic names: variant="info", not variant="blue".
  • Icon props follow the role-first convention: leadingIcon, trailingIcon, triggerIcon, endAdornment (ADR-009).
  • Component-owned affordance icons (chevrons, clear buttons, dismiss) are NOT exposed as consumer props.

Compound Components Pattern

When content regions are heterogeneous (different semantic roles, different field shapes, or fixed structural positions), use compound components via Object.assign:

// index.tsx — the only public file
import { FilterPanel as Root } from './filter-panel'
import { SearchSection } from './search-section'
import { DateRangeSection } from './date-range-section'
import { StatusSection } from './status-section'
export const FilterPanel = Object.assign(Root, {
Search: SearchSection,
DateRange: DateRangeSection,
Status: StatusSection,
})
// Usage — caller composes with JSX, not JSON
<FilterPanel title="Filter Cases">
<FilterPanel.Search placeholder="Search cases..." />
<FilterPanel.DateRange label="Filing date" />
<FilterPanel.Status options={statusOptions} />
</FilterPanel>

When to use compound vs array:

  • Optional fields on an item type → compound (type erosion smell)
  • Internal type discriminator for branching → compound (hidden coupling)
  • Every item has the same fields and same role → plain array prop
  • Fixed structural positions (first/middle/last, not N repeating items) → compound

File layout:

components/filter-panel/
index.tsx ← Object.assign export only
filter-panel.tsx ← root component
search-section.tsx ← one file per sub-component
date-range-section.tsx
status-section.tsx
filter-panel.stories.tsx ← story lives in the component directory

Composition Over Configuration

// GOOD — Composable compound API
<Card>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Action><Button>Add</Button></Card.Action>
</Card.Header>
<Card.Body>{children}</Card.Body>
</Card>
// BAD — Configured with a million props
<Card
title="Users"
actionLabel="Add"
onAction={handleAdd}
headerVariant="compact"
bodyPadding="lg"
showDivider
/>

Component Size Limit: 500 Lines Maximum

No component file should exceed 500 lines. If approaching this limit, decompose aggressively:

  • Extract logical sub-sections into named child components
  • Extract complex conditional rendering into sub-components
  • Extract stateful logic into custom hooks
  • Move large render helpers into separate files

Only exceed 500 lines when no logical decomposition is possible and splitting would genuinely harm readability. This exception requires a comment explaining why.

File & Folder Structure

src/
├── api/ # API client, endpoints, response schemas
│ ├── client.ts
│ ├── users.api.ts
│ └── users.schema.ts # Zod schemas + derived types
├── components/ # Shared dumb components
│ ├── ui/ # Primitive UI (Button, Input, Card, Modal, etc.)
│ │ ├── Button/
│ │ │ ├── index.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ └── Button.stories.tsx
│ │ └── index.ts
│ └── layout/ # Layout components (Shell, Sidebar, Header)
├── features/ # Feature modules (colocated by domain)
│ └── users/
│ ├── components/ # Feature-specific dumb components
│ ├── hooks/ # Feature-specific query/mutation hooks
│ ├── pages/ # Smart route-level components
│ └── index.ts # Public API for the feature
├── hooks/ # Shared custom hooks
├── stores/ # Zustand stores
│ └── app.store.ts
├── lib/
│ └── utils.ts # cn(), helpers
└── routes/ # TanStack Router route definitions

Rules:

  • Feature folders are self-contained — a feature can be deleted without breaking others
  • Smart components (pages) live in features/*/pages/
  • Hooks that call useQuery live in features/*/hooks/
  • Every exportable module has an index.ts barrel file
  • Compound component families live in their own named directory

Code Style & Conventions

  • Naming: PascalCase for components, camelCase for hooks and utils, SCREAMING_SNAKE for constants
  • Files: One component per file. File name matches component name. Max 500 lines.
  • Exports: Named exports for components. Default exports only for route-level pages.
  • Props: Interface above component, suffix Props. Destructure in function signature.
  • Types: Derive from Zod schemas with z.infer. Avoid any — use unknown and narrow.
  • Error handling: Every useQuery must have error UI. Every useMutation must handle onError. Never swallow errors.
  • Accessibility: Semantic HTML first. ARIA only when semantics are insufficient. All interactive elements keyboard accessible. Visible focus rings always.
  • Comments: Explain why, not what. If code needs a comment explaining what it does, refactor for clarity.
  • Tokens: Use semantic design tokens (ui-type-*, ui-pad-*, CSS custom properties) — never hardcoded hex or arbitrary pixel values.