Skip to main content

Component Design

When to use: Building new UI components. Reviewing component architecture. Refactoring existing components.

The test: Can this component be used in isolation, understood in 30 seconds, and tested without the rest of the app?


Creation Checklist

Run this for every new component:

  • Named export -- No anonymous exports (export default function Card, not export default () =>)
  • TypeScript interface -- Props defined with explicit types
  • Semantic HTML -- <button>, <article>, <nav>, not <div> soup
  • Design tokens -- No arbitrary color/spacing values (use theme variables)
  • Destructured props -- With default values at the function signature
  • Single responsibility -- Does one thing well (under 150 lines)
  • Independently testable -- Works in isolation without parent state

Component Types

TypePurposeData SourceLocation
PresentationalDisplay UIProps onlyReusable library
StructuralCompose layoutProps, maps dataPages/views
StatefulManage stateAPI, contextApp-specific

Placement Rule

  • Bottom of tree -- Presentational (most reusable)
  • Middle of tree -- Structural (compose others)
  • Top of tree -- Stateful (app-specific)

Props Design

| Check | Threshold | How to Verify | | ------------------ | ---------------------------------------------- | ---------------------------- | ---- | ----- | | Prop count | 7 or fewer (split component if more) | Count interface properties | | Required props | Only truly required props lack ? | Review TypeScript interface | | Boolean props | No boolean for complex multi-state values | Prefer union types: "sm" | "md" | "lg" | | Children preferred | Use children over render props when possible | Check for renderX props | | Defaults provided | Every optional prop has a sensible default | Check destructuring defaults |

Good Example

interface CardProps {
title: string;
description: string;
variant?: "default" | "highlighted";
onAction?: () => void;
}

function Card({ title, description, variant = "default", onAction }: CardProps) {
return (
<article className={`card card--${variant}`}>
<h3>{title}</h3>
<p>{description}</p>
{onAction && <button onClick={onAction}>Action</button>}
</article>
);
}

File Organization

src/components/
├── design-system/ # Presentational (most reusable)
│ ├── Button.tsx
│ ├── Typography.tsx
│ └── Card.tsx
├── features/ # Structural (feature-specific)
│ ├── auth/
│ └── dashboard/
└── pages/ # Stateful (app-specific)
└── HomePage.tsx
RuleThreshold
One component per fileExceptions only for tightly-coupled sub-components
Co-located testsComponent.test.tsx next to Component.tsx
Co-located stylesCSS modules or styled file next to component
Index files for directoriesRe-export public API only

Conditional Rendering

PatternWhen to Use
Early returnLoading, error, empty states
Logical &&Simple presence check (items.length > 0 && <List />)
TernaryTwo mutually exclusive states
Named variableComplex condition (extract to const isVisible = ...)

Good Example

function UserProfile({ user, isLoading, error }) {
if (isLoading) return <Skeleton />;
if (error) return <ErrorState error={error} />;
if (!user) return null;

return <Profile user={user} />;
}

Anti-Patterns

  • No nested ternaries
  • No inline complex conditions in JSX
  • No index as key in lists (use stable IDs)
  • No inline object/array literals as props (creates new reference each render)
  • No side effects in render body (use useEffect)

Accessibility Checklist

Every component must meet these:

  • Semantic HTML element used (<button> not <div onClick>)
  • Keyboard operable (Enter/Space for buttons, arrow keys for groups)
  • Focus management correct (focus moves logically, returns on close)
  • ARIA attributes present when semantic HTML is insufficient
  • Color contrast passes 4.5:1 for text, 3:1 for UI elements
  • Screen reader announces purpose (test with VoiceOver/NVDA)

Testing Checklist

Test TypeWhat to VerifyTool
Render testMounts without crashingJest/Vitest + Testing Library
Props testEach prop produces expected outputSnapshot or assertion
Event testCallbacks fire on interactionfireEvent or userEvent
Accessibility testZero axe violationsjest-axe or @axe-core/playwright
Visual testLooks correct across statesStorybook + Chromatic or Percy

Bad Component Signals

SignalProblemFix
Over 7 propsToo many responsibilitiesSplit into smaller components
Breaks when movedToo coupled to parentAccept data via props, no document.querySelector
Hard to testHidden dependenciesInject dependencies via props or context
Prop drilling 3+ levelsWrong tree structureUse context or restructure component tree
Over 200 linesDoing too muchExtract sub-components
Requires specific parentTight couplingUse composition and children

Quick Diagnosis

SymptomLikely CauseFix
Re-renders constantlyNew object/array references in propsMemoize or extract to stable reference
Breaks in isolationDepends on parent state or DOMAccept all data via props
Hard to style differentlyHardcoded stylesAccept className prop, use design tokens
Duplicated across featuresNot extracted to design systemMove to design-system/ directory
Tests are fragileTesting implementation detailsTest behavior and output, not internals

Context