Skip to main content
Saved
Foundation 14 min read

Component-Driven Architecture

How frontend development moved from page-based thinking to component-based thinking.

TL;DR

TL;DR

The web grew from documents into applications, and the way we think about building for it has to change too. With component-driven architecture, you turn page-based thinking upside down. Instead of starting from whole pages and working down, you build up from small, reusable parts that each work by themselves.

Core principles:

You build up from small components instead of starting with whole pages
Each component keeps its structure and behavior behind a clearly defined API
You handle variation by combining simple parts, instead of adding more options to one complex part
A component should depend on as little as possible outside its own boundary

The art of programming is the art of organizing complexity, of mastering multitude and avoiding its bastard chaos as effectively as possible.

Edsger W. Dijkstra

The Shift from Documents to Applications

The web started with documents. HTML was designed to mark up text with headings, paragraphs, lists, and links, and pages linked to other pages. The browser was a document viewer, and we were all publishers.

That mental model served us well for a surprisingly long time. We built websites as collections of pages, each one a distinct document with its own URL and its own place in the sitemap. Templates helped us share headers and footers across pages, but the page was still the basic unit we built with.

Then we started building applications, though that change didn’t happen all at once. Gmail in 2004 hinted at what was possible, and single-page applications followed. React arrived in 2013 and changed how most teams built interfaces. It wasn’t the first component library, but it made thinking in components easy to pick up and practical for large apps. Today, whether you use React, Vue, Svelte, Angular, or web components, you’re working with components in a way that a developer from 2005 wouldn’t recognize.

The tools changed, and so did the way we think about building for the web.

The Page Mindset

It’s worth looking at the page model first, because the component model grew out of its limits.

In page-based development, you think top-down. You start with the page and its layout, and work downward into the details. Each page is a place the user goes, and it’s complete by itself. The question you keep coming back to is “What does this page need to do?”

In this model, templates are a way to reuse code. You’d move the header into a partial so you don’t have to repeat it on each page, and you wouldn’t think of “Header” as something that exists separately from the pages that include it.

Server-side frameworks like Rails and Django encouraged this way of thinking too. They organized code around routes and views, so the URL determined which controller ran and which template got rendered, and everything started from the page.

The page model does work well in a lot of ways. Each page has its own URL, which matches how users think about navigation, and you can reason about each page by itself. Pages also match the way designers often deliver mockups, one page at a time, and how the web actually works at the protocol level, where you request a URL and get a document back.

But as your interfaces get more interactive, and the same pieces of UI start to appear on different pages, thinking in pages starts to get in the way.

The Component Mindset

With component-driven architecture, you think the other way around, from the bottom up. Instead of starting with pages and working down to the parts, you start with the parts and put them together into larger pieces.

A component is a piece of interface that has its own structure and behavior. Inputs such as props and slots go into it, and outputs such as rendered UI and events come out, so there’s a clear boundary around it. You can understand it and test it by itself, and you can combine it with other components to build larger parts of the interface.

Instead of asking “what does this page need?”, you ask “what are the building blocks, and how do they fit together?”

When you think in components, you start to see the navigation bar as a separate piece of UI, which just happens to appear at the top of your pages.

The navigation bar has its own state, such as which item is active, and its own logic for how it behaves on mobile. You can build it and test it before any page uses it.

A template is a hole that you fill with content, and a template partial only exists for the page that includes it. A component is different, because it works by itself and can contain other components, and you can use it anywhere you need it.

Because components are independent, you can think at different levels of abstraction at the same time. A button is a component, and so are the card containing it, a list of those cards, and the page that combines that list with a header and a sidebar. Each one works by itself and also as part of the level above it.

The Atomic Metaphor

Brad Frost’s Atomic Design gave us a useful vocabulary for this hierarchy: atoms, molecules, organisms, templates, and pages. The metaphor borrows from chemistry, where small things combine into larger things according to predictable rules.

The Atomic Hierarchy

Each level deals with different things and changes at a different rate. Atoms don't change much, but pages change all the time.

Small to Medium
Atoms
Atoms are the basic building blocks, such as buttons and inputs, which you can't break down any further at this level of abstraction.
Small to Medium
Molecules
Molecules are simple combinations of atoms working together, such as a search form that combines an input field with a submit button.
Small to Medium
Organisms
Organisms are more complex compositions that form distinct sections of interface, like a header containing a logo and navigation.
Medium to Large
Templates
Templates set out the structure of a page with placeholder content, so they give you the layout without the real data.
Medium to Large
Pages
Pages are templates filled in with real content, and they're what users actually see.

The metaphor isn’t perfect, because chemistry has fixed rules and component composition doesn’t, but the hierarchy it describes is real. You usually design the small parts first, but when you use them, you start from the page and work down.

Whether you call the levels atoms and molecules or just “small components and big components”, the idea is that you build complex interfaces out of simpler parts.

Boundaries and Contracts

Components need clear boundaries, because without them they start to depend on each other’s internals and you lose the benefits of encapsulation.

A component’s boundary separates its internal state and implementation details from everything else. Other components can only use it through its defined interface, and they don’t need access to anything inside it.

The component’s API is its contract. Props set what data goes in, events set what signals come out, and slots set where child content goes. You can think of the contract as a promise: “Give me these inputs, and I’ll behave in these ways.”

It's better to keep a component's contract narrow, because each extra prop adds coupling, and each piece of context the component needs is another dependency that makes it harder for you to reuse and test.

The principle of minimal knowledge says that a component should depend on as little as possible outside its boundary. When a component needs something, it’s better to pass it in explicitly than to have the component fetch it for itself. You can spot explicit dependencies and test them, but implicit ones are hard to find, which makes the component harder to understand and easier to break.

A button component needs a label and a function to call when someone clicks it. It doesn’t need any information about which page it’s on or which user is logged in.

Sometimes it’s the right choice for a component to use global state or context, but that should be a decision you make on purpose, where other developers can see it. By default, a component should get what it needs through its props.

Composition Over Configuration

How should components handle variation? When you need a button that’s sometimes primary and sometimes secondary, sometimes with an icon and sometimes without, how do you model that?

Two Approaches to Variation

Configuration Approach

You add a prop for each variant:

<Button
  variant="primary"
  size="large"
  icon="save"
  iconPosition="left"
>
  Save
</Button>

The component turns into a kind of Swiss Army knife, and its API keeps growing as you try to handle each new case.

Composition Approach

You build larger components from smaller ones:

<PrimaryButton>
  <SaveIcon />
  Save
</PrimaryButton>

You get each variation by combining different building blocks, so no single block needs to be complex.

Configuration gets harder to manage as a component grows, because each prop you add multiplies the number of combinations. If you have three sizes, four variants and two icon positions, you have twenty-four combinations to implement and test inside a single component. Composition copes with this better, because each building block only deals with its own job, and you get the combinations by putting the blocks together in different ways.

With composition, the people using your components can also build combinations you didn’t plan for, while a heavily configured component can only do what you expected when you wrote it.

In the compound component pattern, instead of a select component that takes an array of options as a prop, you have a Select that contains Option children. Instead of a tabs component configured with an array of tab definitions, you have Tabs containing Tab and TabPanel children. The parent provides shared context and the children provide the content, so you use the component by choosing which children to put inside it, and in what order.

You can use slots and children the same way. A modal component can accept children and render them in the right places, so it doesn’t need header and footer props, and a card can accept CardHeader and CardBody children instead of title and actions props. The component sets out the structure, and the developer using it fills in the content.

Simple variations that don't change much often work best as props. Once the variations start to multiply, though, I'd usually switch to composition, before the component ends up with dozens of configuration props.

State and Behavior

Components need to manage state, but how much state, and of what kind?

Local state belongs to a single component instance, such as whether a dropdown is open, an input’s current value before the form is submitted, a counter’s count, or which sections of an accordion are expanded. Nothing outside the component can access it.

Because local state is encapsulated, it doesn’t create coupling. When a component manages its own local state, changes to that state don’t affect the rest of the application, and you can use the component anywhere and expect it to work.

Derived state is calculated from other state, such as a filtered list derived from a full list and a search term, or a total derived from line items. You don’t need to store these values, because you can compute them from the source data whenever you need them. If you do store derived state separately, the copies can get out of sync and cause bugs, and computing it each time avoids that, because there’s only one copy of the data.

It gets harder when two components need to react to the same data, because that data has to be stored somewhere they can both access. At that point you’re making decisions about your application’s architecture as well as your components, and it’s where a lot of us struggle.

It’s tempting to lift state all the way up to the root, so it’s available everywhere. That works, but it creates hidden dependencies, because components that look self-contained actually depend on the structure of that global state. If you change that structure, you might break components scattered across your codebase.

In component-driven architecture, you keep state as close as possible to where it's used. You only lift state when you need to share it, and then only as far as the nearest common ancestor of the components that need it.

Global state is sometimes necessary, but every piece of it couples together components that would otherwise be independent.

Server state is data that comes from your server, and your app only caches a local copy of it. Modern data-fetching libraries treat server state as a cache that they keep in sync with the server, instead of as application state that you manage yourself. When you keep the two apart, it’s easier to work out what’s local, what’s shared across this client, and what belongs to the server.

Thinking in Components

Adopting component-driven architecture takes more than using a component framework, because it changes how you look at interfaces.

When you look at a design mockup, do you see pages or building blocks? When you review code, do you check whether the page works or whether each component is coherent?

I’ve found that once you start thinking in components, you look at any complex screen and start breaking it down into parts. Usually the hard part is deciding where the boundaries between those components should go, and building the screen is the easier bit.

Breaking a screen down like this is design work, even if it doesn’t look like traditional design. The decisions you make about where component boundaries fall and what contracts they expose have a large effect on how easy your system is to maintain over time.

If you’ve split a screen up well, you can zoom in to any component and find smaller components inside it, each with its own clear purpose. If you’ve split it up badly, you end up with components that are either too small, each doing so little that you need lots of them to get anything done, or too big, doing everything with a tangled internal structure.

Questions That Reveal Problems

I’ve learned to find the right size for a component mostly through practice, but I keep coming back to the same questions:

  • Does this component have a single, clear purpose? Can I explain what it does in one sentence?
  • Could someone use it without understanding its implementation? Is the API obvious?
  • Would I be comfortable publishing it as a library? Is it self-contained?

If explaining a component’s purpose needs an “and”, such as “it displays user data and handles form submission”, the component is doing too much. If you need to mock half the application to test a component, its dependencies are too broad, and if styling changes break its behavior, the code for how it looks and the code for how it works are too tangled together.

In component-driven development, a lot of refactoring is breaking components apart. When one of your components gets too complex, you usually split it into several smaller ones, each with a simpler job.

Components Across Frameworks

React, Vue, Svelte, Angular, Solid, and web components all work with components. Their syntax and reactivity models are different, but you think about components the same way in all of them.

If you’re a React developer who thinks in components, you can follow how a Vue codebase is put together, even if the code itself looks unfamiliar. A Svelte developer can sketch component boundaries on a whiteboard in a way that a React developer immediately recognizes.

That helps a lot when frameworks come and go, or when you change jobs and have to pick up a new stack. You still need to learn the new framework’s syntax, but you already know how to think in components.

Beyond the Browser

Component thinking has spread beyond frontend development too. Design tools like Figma now have component systems, and mobile developers build their interfaces from components in a similar way.

I don’t think that’s a coincidence, because anywhere you build interfaces, you have to build complex things out of simpler ones without the whole thing turning into a mess. Self-contained parts with clear boundaries and explicit contracts, which you can combine into larger ones, help with that, whatever platform you’re building for.

Pages still exist, because users need somewhere to go, but now we build them by putting components together.

Summary

Component-driven architecture changes how we think about building for the web, and it comes down to six ideas:

  1. Building from the bottom up, starting with small parts instead of whole pages
  2. Encapsulation, with a clear boundary between what’s inside a component and the API that other code uses
  3. Composition over configuration, so you handle variation by combining parts instead of adding more props
  4. Local state by default, lifted only when you need to share it
  5. Design work at every level, because where you put component boundaries affects how easy your code is to maintain
  6. The same way of thinking in any framework, so you can take it with you when your tools change

Building in pages worked well for the web we had, but today’s interfaces are more complex, and component-driven architecture gives you a way of thinking about how to build and maintain them over time.


Related Content:

External Resources:

Next Foundation · 12 min read

Logical Separation of Concerns

Component-based architectures still separate concerns, but they separate frontend code by feature instead of by technology.

Read the next Foundation →

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.