Documentation

Component Design

Stateless by default, container/presentational split, and composition patterns.

Show:

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 any props.
  • 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} />.