Component-driven development flips the traditional top-down approach to building user interfaces. Instead of building pages and extracting reusable parts afterward, you start with individual components and compose them into views.
The payoff is not just reuse. When a component is built and exercised on its own, its edge cases become visible early, its API has to be explicit, and the page that eventually uses it is mostly assembly. The cost is a little more up-front thinking about boundaries, which this article tries to make cheaper.
The Component Hierarchy
Organize components in a clear hierarchy: atoms (buttons, inputs, labels), molecules (search bars, form groups), organisms (navigation, hero sections), templates (page layouts), and pages (specific instances). This atomic design methodology, popularized by Brad Frost, provides a shared vocabulary for discussing UI architecture.
Treat the hierarchy as a vocabulary, not a folder structure you must obey. Teams often get stuck arguing whether a card is a molecule or an organism. A more useful split for code is:
- Primitives that know nothing about your domain:
Button,Input,Badge,Dialog. - Compositions that arrange primitives into a recognizable pattern:
SearchField,StatCard,EmptyState. - Features that bind compositions to real data and behavior:
InvoiceTable,CheckoutSummary.
The rule that keeps this healthy is that dependencies only point downward. A primitive never imports a feature, and a composition never fetches data.
Developing in Isolation
Build and test components outside the context of a full page. Tools like Storybook, or Astro’s Container API for rendering a component on its own, allow you to render components with different props, states, and data variations. This catches edge cases early and produces more robust components.
Isolation forces you to answer questions a full page lets you ignore. What does the table look like with zero rows? With one row whose name is 80 characters long? While loading? After the request fails? Write each of those down as a named state and render it:
export const states = {
loading: { status: 'loading' },
empty: { status: 'ready', rows: [] },
error: { status: 'error', message: 'Could not load invoices.' },
long: { status: 'ready', rows: [longNameRow, ...sampleRows] },
};
Isolated development also makes visual regression testing practical. Snapshot your component states and automatically detect unintended visual changes between releases. Keep the snapshots focused — one component state per image — so a failing diff points straight at the cause instead of a whole page.
Props and Data Flow
Design clear component APIs with well-defined props. Use TypeScript interfaces to document expected inputs and provide sensible defaults. Favor explicit prop passing over context or global state for most components — it makes data flow traceable and components easier to test.
A few habits make APIs that age well:
- Model states, not booleans.
isLoading,isErrorandisEmptyas separate flags allow impossible combinations. A singlestatusunion does not. - Name variants by intent.
variant="danger"survives a rebrand;color="red"does not. - Accept children or slots for content. A component that takes
title,subtitle,iconandfooterTextprops will keep growing; one that accepts composed content will not. - Pass through native attributes. A
Buttonshould accepttype,disabledandaria-*without each one being re-declared.
type Status = 'loading' | 'empty' | 'error' | 'ready';
interface StatCardProps {
/** Short label shown above the value. */
label: string;
/** Formatted value; ignored unless status is 'ready'. */
value?: string;
status: Status;
}
Context and global stores still have a place: theme, locale, the current user. Those are genuinely ambient. Most other data should arrive through props, because a component whose inputs are all visible in its signature can be rendered anywhere, including in a test.
Testing Components
Unit tests verify that components render correctly with given props and handle user interactions as expected. Integration tests validate that composed components work together correctly. Use Testing Library’s guiding principle: test behavior, not implementation details.
In practice that means querying by what a user perceives — role, accessible name, visible text — rather than by class names or internal state:
test('shows the error message and a retry button', async () => {
render(InvoiceTable, { status: 'error', message: 'Could not load invoices.' });
expect(screen.getByText('Could not load invoices.')).toBeVisible();
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled();
});
A test written this way survives a refactor of the markup and also doubles as a light accessibility check: if you cannot find the button by its role and name, neither can a screen reader. Pure logic, such as the function that maps a variant to class names, is easiest to test directly as a plain function with no rendering at all.
Documenting as You Build
Isolation gives you documentation almost for free, as long as you capture it while the decisions are fresh. For each component, record:
- What it is for, and just as importantly, what it is not for.
- Every prop, with its type, default and a one-line description.
- The states it supports and what each one looks like.
- Keyboard behavior and any ARIA it manages on the consumer’s behalf.
Put the descriptions in the type definitions themselves, as in the StatCardProps example above, so editors surface them on hover and they cannot drift away from the code.
Where It Goes Wrong
Component-driven development has failure modes worth naming:
- Premature abstraction. Extract a shared component once you have two or three real uses, not one imagined future use.
- Prop explosion. When a component has more boolean props than it has lines of markup, split it into smaller pieces.
- Isolation without integration. Every component can look perfect in its own story while the page still breaks. Keep a small number of page-level tests as well.
Conclusion
Component-driven development produces interfaces that are more consistent, easier to maintain, and faster to build. Start with the smallest building blocks, give each one an explicit API and a full set of states, and compose your way up.