Component Documentation and Contracts
What Is a Component Contract?
Every component makes implicit promises. A Button promises to call onClick when clicked. A Dialog promises to trap focus. A Select promises to close when you press Escape. These are contracts.
The problem: most contracts are invisible. They live in a developer's head, not in the code. When that developer leaves, the contracts leave with them. The next person unknowingly breaks a contract, and users pay the price.
Component contracts make promises explicit, testable, and enforceable.
Think of component contracts like a restaurant menu with allergen information. The dish (component) is not just "pasta" — it comes with guarantees: "contains gluten, dairy-free, serves 2." If the chef changes the recipe and adds dairy, the menu (contract) flags the breaking change immediately. Without the menu, a customer with a dairy allergy discovers the change the hard way.
TypeScript as Your First Contract Layer
TypeScript types are your most powerful contract tool. They enforce constraints at compile time — before the code ever runs.
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
modal?: boolean;
}
This type contract says: Dialog requires open (boolean) and onOpenChange (callback). It optionally accepts modal. It does not accept className, style, or any other prop. TypeScript enforces this at every call site.
Discriminated Unions as Behavioral Contracts
Types can encode behavioral rules, not just data shapes:
type NotificationProps =
| {
type: "success";
message: string;
action?: { label: string; onClick: () => void };
duration?: number;
}
| {
type: "error";
message: string;
action: { label: string; onClick: () => void };
duration?: never;
}
| {
type: "loading";
message: string;
action?: never;
duration?: never;
};
This contract encodes three behavioral rules:
- Error notifications must have an action (users need a way to retry or dismiss)
- Error notifications cannot auto-dismiss (users need time to read and act)
- Loading notifications cannot have actions or durations (they dismiss when the operation completes)
These are design decisions encoded in the type system. A new developer cannot violate them.
TSDoc for Component Documentation
TSDoc (TypeScript's documentation standard) lets you add structured documentation that editors like VS Code surface in hover tooltips:
/**
* A dialog overlay that requires user interaction before continuing.
*
* @remarks
* Built on Radix Dialog. Automatically traps focus, closes on Escape,
* and restores focus to the trigger element when closed.
*
* @example
* ```tsx
* <Dialog open={isOpen} onOpenChange={setIsOpen}>
* <DialogContent>
* <DialogTitle>Confirm deletion</DialogTitle>
* <DialogDescription>This cannot be undone.</DialogDescription>
* <Button onClick={() => setIsOpen(false)}>Cancel</Button>
* <Button variant="destructive" onClick={handleDelete}>Delete</Button>
* </DialogContent>
* </Dialog>
* ```
*/
interface DialogProps {
/**
* Controls whether the dialog is visible.
* Must be managed by the parent component (controlled pattern).
*/
open: boolean;
/**
* Called when the dialog's open state should change.
* Triggered by: Escape key, overlay click, or programmatic close.
*
* @param open - The new open state
*/
onOpenChange: (open: boolean) => void;
/**
* When true, prevents interaction with elements outside the dialog.
* @defaultValue true
*/
modal?: boolean;
children: ReactNode;
}
When a developer hovers over Dialog or any of its props in their editor, they see this documentation. No need to open a separate docs site.
- 1Document the WHY in TSDoc, not the WHAT — the type signature already shows what a prop is
- 2Include a code example in the component-level TSDoc comment
- 3Document default values with @defaultValue — editors display this in hover tooltips
- 4Document behavioral contracts: what events trigger callbacks, what side effects occur
Runtime Contracts (Development Only)
TypeScript catches type errors. But some contracts are behavioral and cannot be expressed in types:
function Tabs({ children, value, defaultValue }: TabsProps) {
if (process.env.NODE_ENV === "development") {
if (value !== undefined && defaultValue !== undefined) {
console.error(
"Tabs: Received both `value` and `defaultValue`. " +
"A component can be either controlled or uncontrolled, not both. " +
"Use `value` + `onValueChange` for controlled, or `defaultValue` for uncontrolled.",
);
}
const childValues = Children.toArray(children)
.filter(isValidElement)
.map((child) => (child.props as { value?: string }).value)
.filter(Boolean);
const duplicates = childValues.filter(
(v, i) => childValues.indexOf(v) !== i,
);
if (duplicates.length > 0) {
console.error(
`Tabs: Duplicate tab values found: ${duplicates.join(", ")}. Each tab must have a unique value.`,
);
}
}
// ... rest of implementation
}
These checks only run in development. They are stripped from production builds by the bundler (dead code elimination on process.env.NODE_ENV). They catch mistakes that TypeScript cannot: conflicting props, duplicate values, invalid combinations.
PropTypes in the TypeScript Era
PropTypes still exist. Should you use them?
| Aspect | TypeScript Types | PropTypes |
|---|---|---|
| When errors appear | Compile time (in editor) | Runtime (in browser console) |
| Production cost | Zero — types are stripped | Bundle size + runtime cost unless removed |
| Expressiveness | Full language — generics, unions, mapped types | Limited — basic shapes and custom validators |
| Third-party consumers | Require TypeScript | Work in JavaScript projects |
| Behavioral validation | Cannot check dynamic constraints | Can validate cross-prop relationships at runtime |
The verdict: if your entire codebase is TypeScript, PropTypes are redundant. TypeScript types catch more errors earlier. The one exception: if you publish a component library that JavaScript consumers use, PropTypes provide runtime validation they would otherwise lack.
Some teams use both TypeScript and PropTypes "for extra safety." This doubles the maintenance burden and they inevitably drift apart. The PropTypes say one thing, the TypeScript types say another. Pick one source of truth. For TypeScript projects, that source is TypeScript.
Testing Contracts
Tests are the executable specification of your component contracts. But test the right things:
Test the contract (behavior), not the implementation (how it works)
// BAD: testing implementation
it("sets isOpen state to true when trigger is clicked", () => {
const { result } = renderHook(() => useDialogState());
act(() => result.current.open());
expect(result.current.isOpen).toBe(true);
});
// GOOD: testing the contract
it("opens dialog when trigger is clicked", async () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Dialog content</DialogContent>
</Dialog>,
);
expect(screen.queryByText("Dialog content")).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Open"));
expect(screen.getByText("Dialog content")).toBeVisible();
});
// GOOD: testing accessibility contract
it("traps focus inside the dialog when open", async () => {
render(
<Dialog defaultOpen>
<DialogContent>
<input data-testid="first" />
<input data-testid="second" />
<button>Close</button>
</DialogContent>
</Dialog>,
);
const firstInput = screen.getByTestId("first");
const closeButton = screen.getByText("Close");
firstInput.focus();
await userEvent.tab();
expect(screen.getByTestId("second")).toHaveFocus();
await userEvent.tab();
expect(closeButton).toHaveFocus();
await userEvent.tab();
expect(firstInput).toHaveFocus();
});
// GOOD: testing keyboard contract
it("closes on Escape key", async () => {
const onOpenChange = vi.fn();
render(
<Dialog defaultOpen onOpenChange={onOpenChange}>
<DialogContent>Content</DialogContent>
</Dialog>,
);
await userEvent.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
Auto-Generating Docs from TypeScript
Modern tools extract documentation from your TypeScript types automatically:
Storybook Autodocs
With tags: ['autodocs'] in your story meta, Storybook generates a documentation page with:
- Component description (from TSDoc)
- Props table (from TypeScript interface)
- Default values (from
@defaultValueor default params) - Live examples (from stories)
- Controls (interactive prop playground)
TypeDoc and API Extractor
For published libraries, tools like TypeDoc generate static API documentation from TSDoc comments:
npx typedoc --entryPoints src/index.ts --out docs
react-docgen-typescript
Next.js and Storybook use react-docgen-typescript to extract prop information:
// This component's props are automatically extracted
interface CardProps {
/** The card's visual style */
variant?: "default" | "bordered" | "elevated";
/** Additional CSS classes */
className?: string;
children: ReactNode;
}
The tool reads the interface, extracts prop names, types, descriptions, and default values, and generates JSON that Storybook or a docs site can render.
Versioning and Breaking Changes
When a component contract changes, consumers break. Managing this requires discipline:
interface ButtonProps {
variant?: "primary" | "secondary" | "ghost";
/**
* @deprecated Use `variant` instead. Will be removed in v3.0.
* Migration: `color="blue"` becomes `variant="primary"`
*/
color?: "blue" | "gray" | "red";
}
function Button({ variant, color, ...props }: ButtonProps) {
if (process.env.NODE_ENV === "development" && color) {
console.warn(
'Button: `color` prop is deprecated. Use `variant` instead. ' +
'Migration: color="blue" → variant="primary", ' +
'color="gray" → variant="secondary", ' +
'color="red" → variant="destructive".',
);
}
const resolvedVariant = variant ?? colorToVariant(color) ?? "primary";
// ...
}
| What developers do | What they should do |
|---|---|
| Documenting components in a separate Notion/Confluence page External docs go stale immediately. Code-level docs stay current because they live next to the code they describe. Engineers update the code and the docs in the same commit. | Keep documentation in the code (TSDoc) and auto-generate from it (Storybook autodocs) |
| Testing internal state and implementation details of components Implementation tests break on every refactor even when behavior is unchanged. Contract tests verify what matters to users and survive refactors. | Test the user-facing contract: what renders, what happens on interaction, what accessibility guarantees hold |
| Removing a prop or changing its type without a deprecation period Silent breaking changes cause cascading failures in codebases that depend on your component. Deprecation warnings give consumers time to migrate on their own schedule. | Deprecate with a warning, support both old and new API for one major version, then remove |
The Documentation Stack
For a mature component library, here is the recommended documentation stack:
| Layer | Tool | Audience | Updates |
|---|---|---|---|
| Type contracts | TypeScript interfaces with TSDoc | Engineers using the component | Auto — types are the code |
| Visual docs | Storybook with autodocs | Engineers, designers, QA | Auto — stories render live components |
| Interaction tests | Storybook play functions | Engineers, QA | Manual — written alongside stories |
| API reference | TypeDoc or API Extractor | External consumers | Auto — generated from TSDoc |
| Migration guides | CHANGELOG.md + deprecation warnings | All consumers | Manual — written at release time |
The key insight: four out of five layers are auto-generated from the code itself. Only migration guides require manual writing. This is how documentation stays current at scale.
You maintain a component library used by 12 teams across your organization. A team requests a change to the Dropdown component that would alter the keyboard navigation behavior. How do you evaluate whether this is a breaking change? How would you communicate it? What tests would verify the contract is maintained? Walk through your decision process from request to release.