top of page

Component Architecture That Scales

  • ShiftQuality Contributor
  • May 10
  • 5 min read

Every modern frontend framework — React, Vue, Svelte, Angular — is built on the same idea: components. Small, reusable pieces that compose into larger interfaces. The concept is simple. The execution is where things get complicated.

Tutorials show you how to build a component. They don't show you how to organize 200 of them. They don't show you what happens when a button component needs to behave differently in twelve contexts, or when three developers create three different card components because nobody knew the other ones existed.

Component architecture is the difference between a frontend that stays manageable at 50,000 lines of code and one that becomes a maze at 5,000.

The Component Spectrum

Not all components serve the same purpose. Understanding the spectrum prevents the most common architectural mistake: treating every component the same way.

Primitive Components

The smallest building blocks. A button, a text input, a label, an icon. They accept props for configuration but contain no business logic and know nothing about the rest of the application.

function Button({ variant = "primary", size = "md", children, ...props }) {
  return (
    <button className={`btn btn-${variant} btn-${size}`} {...props}>
      {children}
    </button>
  );
}

Primitive components should be ruthlessly generic. They don't know whether they're in a checkout flow or an admin panel. They render based on their props and nothing else.

Composite Components

Combinations of primitives that form a recognizable UI pattern. A search bar (input + button + icon), a card (image + title + description + action), a form field (label + input + error message).

function FormField({ label, error, children }) {
  return (
    <div className="form-field">
      <label>{label}</label>
      {children}
      {error && <span className="error">{error}</span>}
    </div>
  );
}

Composite components encode layout and visual relationships. They still shouldn't contain business logic — they don't know what the form is for or what happens when you click the button.

Feature Components

Components that implement a specific piece of functionality. A login form, a product card that adds items to a cart, a comment thread that handles posting and loading.

Feature components use primitives and composites but add behavior — API calls, state management, business rules. They know about the application's domain.

function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const { login, error, loading } = useAuth();

  return (
    <form onSubmit={() => login(email, password)}>
      <FormField label="Email" error={error?.email}>
        <Input value={email} onChange={setEmail} />
      </FormField>
      <FormField label="Password" error={error?.password}>
        <Input type="password" value={password} onChange={setPassword} />
      </FormField>
      <Button loading={loading}>Sign In</Button>
    </form>
  );
}

Page Components

The top-level components that correspond to routes. They compose feature components, handle page-level layout, and manage data fetching for the page. Ideally thin — mostly composition with minimal logic.

The principle: as you move up the spectrum from primitives to pages, components become more specific, more business-aware, and less reusable. That's intentional. Reusability decreases as specificity increases, and that's the correct trade-off.

The Three Rules

1. Props Down, Events Up

Data flows down through props. User actions flow up through callbacks (or events, depending on the framework). This is the single most important rule in component architecture, and violating it is the single most common source of frontend bugs.

When a child component needs to change something, it doesn't reach up into the parent's state. It calls a callback that the parent provided. The parent decides what to do. This keeps the data flow predictable — you can trace any value to its source by following props downward, and trace any state change to its trigger by following callbacks upward.

The moment a component reaches into global state, modifies a parent's data directly, or triggers side effects that other components depend on without clear interfaces — the architecture starts to rot. It will work today. It will be a nightmare to debug in three months.

2. One Component, One Responsibility

A component should do one thing. Not "one thing and also handle this edge case." Not "one thing and also fetch data and also manage local state and also handle errors."

When a component grows beyond its original purpose, it usually means it should be split. A UserProfile component that handles profile display, profile editing, avatar upload, and password changes isn't one component — it's four components wearing a trench coat.

The smell test: can you describe what this component does without using the word "and"? If not, it's doing too much.

3. Co-locate What Changes Together

Files that change together should live together. If every time you modify the UserCard component you also modify its styles, its tests, and its types — those files should be in the same directory.

components/
  UserCard/
    UserCard.tsx
    UserCard.test.tsx
    UserCard.module.css
    index.ts

This is more maintainable than grouping all components in one folder, all styles in another, and all tests in a third. When you need to modify UserCard, everything you need is in one place. When you need to delete UserCard, you delete one folder.

Organizing at Scale

The Feature-Based Structure

For applications with more than 30-40 components, organize by feature rather than by type.

features/
  auth/
    LoginForm.tsx
    SignupForm.tsx
    useAuth.ts
  dashboard/
    DashboardPage.tsx
    StatsCard.tsx
    ActivityFeed.tsx
  billing/
    PricingTable.tsx
    CheckoutForm.tsx
    useBilling.ts
shared/
  components/
    Button.tsx
    Input.tsx
    FormField.tsx
    Card.tsx
  hooks/
    useLocalStorage.ts
    useDebounce.ts

shared/ holds the primitives and composites that are used across features. Feature folders hold the feature-specific components, hooks, and utilities. This structure makes it obvious where a new component belongs and prevents the "flat folder with 200 files" problem.

Shared Components Need Boundaries

The shared/ folder is the highest-risk area of a component library. Without boundaries, it accumulates components that were "shared" between two features once and never used again, components that grew feature-specific props to serve one context, and components that nobody's sure are still in use.

A component earns its place in shared/ when it's used by three or more features. Before that, it lives in the feature that created it. If a second feature needs it, duplicate it temporarily — duplication is cheaper than premature abstraction. If a third feature needs it, extract it to shared/ with a clean, generic API.

When Components Get Complicated

The Props Explosion

A component that started with three props now has fifteen. Some are strings, some are booleans, some are callbacks. Some combinations don't make sense together. The TypeScript definition is 40 lines long.

This is a sign the component is serving too many masters. Split it into variants — PrimaryButton and IconButton instead of Button with variant, icon, iconPosition, loading, loadingText, size, block, and as.

Fewer, more specific components beat one component with a complicated API. The mental overhead of choosing between three components is lower than the mental overhead of remembering which combination of fifteen props does what you want.

The Wrapper Hell

When you find yourself wrapping a component inside a component inside a component just to add a bit of context or transformation at each level, the abstraction isn't helping. Flatten the hierarchy. Pass the data directly to the component that needs it.

Context providers are sometimes used to avoid prop drilling, which is valid. But five levels of nested providers are just a different kind of hell. Use them sparingly and prefer explicit props for most data flow.

Key Takeaway

Component architecture scales when you respect the component spectrum (primitives, composites, features, pages), enforce props-down-events-up data flow, keep each component focused on one responsibility, co-locate related files, organize by feature at scale, and resist the urge to prematurely abstract. The goal isn't elegant architecture. It's a codebase that's still comprehensible six months from now.

Next in the Modern Web Architecture path: Performance optimization — making your web application fast without premature optimization or needless complexity.

Comments


bottom of page