Component Design
Stateless by default, container/presentational split, and composition patterns.
Framework implementations
Abstract reference: 08-frontend-architecture/04-component-organization.md
Stateless by Default
Components should derive all their rendered output from props. Local useState is acceptable for UI state that has no meaning outside the component (open/closed, hover, focus). Server state and shared state belong in hooks, not in component state.
// ✅ Good — stateless, all data from props
interface AccountCardProps {
account: Account
onDelete: (id: string) => void
isDeleting?: boolean
}
export function AccountCard({ account, onDelete, isDeleting = false }: AccountCardProps) {
return (
<div className="card">
<h3>{account.display_name}</h3>
<p>{account.email}</p>
<button
onClick={() => onDelete(account.id)}
disabled={isDeleting}
className="btn-danger"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</button>
</div>
)
}
// ❌ Bad — fetches its own data (makes it hard to test and reuse)
export function AccountCard({ accountId }: { accountId: string }) {
const { data: account } = useAccount(accountId) // Don't do this in a display component
// ...
}Container / Presentational Split
Container components handle data fetching and pass data to presentational components:
// Container — fetches data, handles mutations
export function AccountListContainer() {
const { data: accounts = [], isLoading } = useAccounts()
const deleteAccount = useDeleteAccount()
return (
<AccountList
accounts={accounts}
isLoading={isLoading}
onDelete={(id) => deleteAccount.mutate(id)}
isDeletingId={deleteAccount.isPending ? deleteAccount.variables : undefined}
/>
)
}
// Presentational — pure rendering, no hooks
export function AccountList({
accounts,
isLoading,
onDelete,
isDeletingId,
}: AccountListProps) {
if (isLoading) return <AccountListSkeleton />
if (!accounts.length) return <EmptyState message="No accounts yet." />
return (
<ul>
{accounts.map(account => (
<AccountCard
key={account.id}
account={account}
onDelete={onDelete}
isDeleting={isDeletingId === account.id}
/>
))}
</ul>
)
}Composition Over Configuration
Prefer composing small components over large components with many props:
// ✅ Composable — flexible, clear responsibility
<Card>
<CardHeader>
<h2>Account Details</h2>
</CardHeader>
<CardBody>
<AccountForm account={account} />
</CardBody>
<CardFooter>
<Button onClick={handleSave}>Save</Button>
</CardFooter>
</Card>
// ❌ Overloaded — too many props, unclear responsibility
<Card
title="Account Details"
body={<AccountForm account={account} />}
footerButton={{ label: 'Save', onClick: handleSave }}
headerIcon="user"
variant="elevated"
padding="large"
/>Component Props Patterns
Optional props with defaults:
interface ButtonProps {
children: React.ReactNode
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
isLoading?: boolean
onClick?: () => void
}
export function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
isLoading = false,
onClick,
}: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }))}
disabled={disabled || isLoading}
onClick={onClick}
>
{isLoading ? <Spinner /> : children}
</button>
)
}Forwarding HTML attributes (for primitive components):
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string
error?: string
}
export function Input({ label, error, ...props }: InputProps) {
return (
<div className="field">
<label>{label}</label>
<input className={cn('input', error && 'input-error')} {...props} />
{error && <p className="error-text">{error}</p>}
</div>
)
}Loading & Error States
Every component that renders async data must handle loading and error states explicitly:
export function AccountDetailPage({ accountId }: { accountId: string }) {
const { data: account, isLoading, error } = useAccount(accountId)
if (isLoading) return <AccountDetailSkeleton />
if (error) return <ErrorState message={error.message} />
if (!account) return <EmptyState message="Account not found." />
return <AccountDetail account={account} />
}Use skeleton components (not spinners) for content-shaped loading states — they reduce layout shift.
Rules
- Display components are pure functions of their props — no hooks that fetch data.
- Keep component files under ~150 lines. If a component is growing large, split it.
- Avoid prop drilling beyond 2 levels — if data needs to go deeper, lift it into a hook or context.
- Use TypeScript interfaces for all component props — no untyped
anyprops. - Event handler props are always named
on<Event>(e.g.onDelete,onChange,onSubmit). - Boolean props that are false by default can be written without a value:
<Button disabled />instead of<Button disabled={true} />.