Architectural Principles (Non-Negotiable)
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/expert-react-frontend-engineer/architectural-principles.md |
| Description | Not 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 Zodexport 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
useEffectfor 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 componentinterface 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 datafunction 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.
- Check
dmwd/*Storybook tools for existing primitives and patterns - Search
src/components/ui/(Layer 2) andsrc/components/patterns/(Layer 3) - Check the auto-generated catalog:
pnpm run catalog - If a close match exists, extend via CVA variants, composition, or slots — don’t duplicate
- 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
classNameprop for style overrides viacn() - 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", notvariant="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 fileimport { 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
typediscriminator 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 directoryComposition 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 definitionsRules:
- Feature folders are self-contained — a feature can be deleted without breaking others
- Smart components (pages) live in
features/*/pages/ - Hooks that call
useQuerylive infeatures/*/hooks/ - Every exportable module has an
index.tsbarrel 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. Avoidany— useunknownand narrow. - Error handling: Every
useQuerymust have error UI. EveryuseMutationmust handleonError. 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.