DEV Community

i am message
i am message

Posted on AI-assisted

Component Architecture: Co-location, Composition, and Separation of Concerns, Different soups...

One thing I think frontend engineers need to be more deliberate about is how we apply software design principles when building component-based applications.

There is a tendency to treat co-location, separation of concerns, reusability, and Atomic Design as though they all mean the same thing: breaking code into smaller pieces and organizing those pieces into folders. They do not. Each principle addresses a different problem, and misunderstanding the distinction can lead to abstractions that look reusable on the surface but become increasingly difficult to maintain.

1. Co-location is about proximity, not combining everything into one component

Co-location means keeping code close to the part of the application it relates to. The underlying idea is that code that changes together should generally be located together.

For example, suppose a feature has a component, its validation schema, feature-specific hooks, helper functions, and tests. Keeping those things within the feature's module makes it easier to understand, modify, and maintain that feature without navigating an unrelated collection of global folders.

But co-location does not mean combining all those responsibilities into one component or file.

Consider a password input that is used only by an authentication feature. It makes sense to keep its implementation close to the authentication feature while it remains specific to that context. If it eventually becomes useful across multiple features, we can extract it into a shared UI module.

The important distinction is that co-location determines where code belongs; separation of concerns determines what responsibilities that code should have.

These principles complement each other. They do not compete.

2. Separation of concerns is about responsibility

Separation of concerns means dividing a system into units with clearly defined responsibilities. A component should have a coherent purpose and a contract that makes its behavior understandable.

Consider a generic input component. Its responsibility might include rendering a native input, forwarding standard input attributes, supporting accessibility, and applying the design system's styling.

Now imagine extending that same component to handle all of the following:

  • Standard text inputs.
  • Password inputs with visibility toggles.
  • OTP inputs with multiple digit slots.
  • Grouped inputs with prefixes, suffixes, or multiple fields.
  • Input-specific validation, formatting, keyboard navigation, and specialized error handling.

We could expose a type prop and several additional flags, then use conditional statements to decide which implementation to render.

That might appear efficient because everything is available through one component. However, the component is gradually taking responsibility for several different interaction patterns, each with its own state, behavior, accessibility requirements, and testing needs.

The problem is not simply that the file has become large. The deeper problem is that its interface and implementation are now responsible for too many distinct concerns.

A change to OTP navigation should not require us to reason about password visibility. A change to password visibility should not risk breaking the behavior of an ordinary text field.

A shared component should provide a stable foundation, not become a container for every component that happens to involve an input.

3. Atomic Design helps us reason about the level of abstraction

Atomic Design provides a useful vocabulary for organizing interfaces into atoms, molecules, organisms, templates, and pages.

A basic input can be treated as an atom: a foundational UI primitive. A form field combining a label, input, and error message can be treated as a molecule. A larger form section combining several fields, validation feedback, and submission controls might be an organism, depending on its scope and complexity.

However, Atomic Design is a mental model, not a rigid classification system. The correct level of abstraction depends on what a component does, what it depends on, and how it is used.

For example:

  • Input: A generic, reusable primitive with a clearly defined contract.
  • PasswordInput: A specialized component that composes the generic input with password-specific behavior, such as a visibility toggle.
  • OTPInput: A specialized component that manages multiple digit positions, focus movement, and keyboard interactions.
  • InputGroup: A composition that combines an input with adjacent elements, such as a prefix, suffix, or action.
  • FormField: A higher-level composition that associates a control with its label, description, and validation feedback.

These are not necessarily five atoms. They operate at different levels of abstraction.

The goal is not to create a separate component for every possible variation. The goal is to ensure that each abstraction represents a coherent responsibility and that higher-level components build on lower-level primitives rather than forcing every behavior into one primitive.

4. Composition is how we build specialized behavior without bloating foundational components

Composition allows us to build complex interfaces by combining smaller components with well-defined responsibilities.

Instead of making the generic Input component understand every possible input pattern, we can build specialized components around it.

For example:

function PasswordInput(props: InputProps) {
  const [visible, setVisible] = useState(false);

  return (
    <div className="password-input">
      <Input
        {...props}
        type={visible ? "text" : "password"}
      />

      <button
        type="button"
        onClick={() => setVisible((value) => !value)}
        aria-label={visible ? "Hide password" : "Show password"}
      >
        {visible ? <EyeOff /> : <Eye />}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a simplified illustration, but it demonstrates the architectural distinction: the generic input remains responsible for rendering an input, while the password component owns the additional password-specific interaction.

The OTP input can have its own implementation for digit management, focus movement, and keyboard behavior. It may reuse the same design tokens, styling conventions, and lower-level primitives without forcing the generic input to implement OTP-specific logic.

Likewise, an input group can compose a generic input with prefixes, suffixes, or action buttons.

The objective is not to eliminate conditional rendering. Conditional rendering is perfectly reasonable when it expresses a genuine variation within a component's responsibility. The problem arises when a component becomes a large conditional dispatcher for unrelated behaviors.

Composition gives us a way to reuse implementation without coupling every consumer to every other use case.

React supports this through component nesting, children, and explicit props. A component can provide a structural foundation while allowing its consumer to determine what content or specialized component belongs inside it.

5. Reusability does not mean putting every use case behind one API

A common architectural mistake is assuming that if two components share a visual element, they should necessarily be represented by one highly configurable component.

But visual similarity does not always imply behavioral similarity.

A password input, an OTP input, and a grouped input all involve inputs, but their interaction models differ substantially. Their shared foundation does not justify merging their responsibilities.

A better approach is to reuse at the appropriate level:

  • Reuse design tokens for consistent spacing, typography, colors, and sizing.
  • Reuse generic primitives for common rendering and styling.
  • Compose specialized components from those primitives where it makes sense.
  • Keep feature-specific behavior within the feature that owns it.
  • Promote a feature-specific abstraction into a shared module when there is a genuine need for broader reuse.

This also avoids premature abstraction. A component does not need to become globally available simply because it has been written once. Reuse should emerge from a clear contract and a real need, rather than from the desire to eliminate every instance of duplication.

6. Component boundaries and code splitting are related, but they are not the same thing

There is another important distinction here.

Keeping unrelated implementations together can make architectural boundaries harder to understand and maintain. It can also contribute to unnecessarily large bundles when the module dependency graph causes code to be included together.

However, putting components in separate files does not automatically guarantee code splitting. Modern bundlers determine chunks from the import graph, build configuration, and dynamic import boundaries.

If an OTP implementation is only needed on a particular route, a deliberate dynamic import or lazy-loaded feature boundary may allow that code to be loaded separately. Merely extracting OTPInput into its own file does not guarantee this result.

The architectural benefit of separation is that it gives us clearer units of responsibility and makes independent loading, testing, and evolution easier to reason about. The bundler still needs the appropriate module boundaries to produce the desired output.

We should avoid confusing a good component architecture with an automatic performance optimization.

7. Why this matters even more in the AI era

AI can generate components, refactor files, create abstractions, and reproduce patterns very quickly. But generating code that works is not the same as designing a system that remains maintainable as it grows.

If we ask an AI tool to build a reusable input component without defining the intended boundaries, it may produce a single component with a long list of props, conditional branches, and flags to accommodate every foreseeable use case.

That implementation may work. It may even look elegant in a small example. But it can introduce hidden coupling, unclear contracts, and unnecessary complexity as more requirements are added.

The problem is not that AI inherently produces poor architecture. It is that AI needs architectural constraints and evaluation criteria, just as a human engineer does. Without them, local convenience can take precedence over the long-term structure of the application.

This is where engineering principles become more valuable, not less.

A design system should do more than standardize colors, spacing, typography, and component appearance. It should also establish rules for component responsibility, composition, dependency direction, state ownership, and reuse.

For example, a design system should make the following expectations explicit:

  • Foundational UI primitives should remain focused on their core responsibilities.
  • Specialized interactions should be composed from existing primitives where appropriate.
  • Feature-specific logic should remain close to the feature that owns it.
  • Shared components should expose clear, strongly typed contracts.
  • New abstractions should be introduced when they address a real need, not merely to make code appear more reusable.
  • Generated code should be reviewed for architectural fit, not only whether it renders correctly.

These rules give an AI coding tool a set of constraints against which to evaluate its own implementation. They also make code review more objective because the team can assess whether the implementation follows an agreed architectural model rather than relying solely on individual preferences.

Conclusion

Good component architecture shouldn't be about creating the smallest possible components or the fewest possible files. It should be about finding the right boundaries between responsibilities.

Co-location tells us where related code should live. Separation of concerns tells us which responsibilities should remain distinct. Atomic Design gives us a vocabulary for understanding levels of UI abstraction. Composition gives us a mechanism for building richer interfaces from focused components.

When these principles are applied together, a design system becomes more than a collection of reusable UI elements. It becomes an architectural contract that helps developers and AI tools build software that can evolve without accumulating unnecessary complexity.

Top comments (1)

Collapse
 
phongdesigns profile image
Phong Designs AI System •

Section 7 lands, and there's one bit I'd sharpen. A constraint stops a human because when it makes the task awkward they come back and argue about it. A model just satisfies it somewhere else.

Tell it not to add props to the base Input and it'll obey perfectly. What you get is InputWithIcon, InputWithPrefix, InputSearch, and a folder that grew while the prop table stayed clean. Nothing in the rule was broken. The coupling moved out of a props table and into a directory, which is harder to spot in review because every file looks sensible on its own.

What held up for us was pairing each constraint with something countable, so the escape route gets counted too. Max props on the base component, max files in its folder, both checked in CI. We only noticed the folder growth because somebody graphed file count per directory over six months.

The evaluation criteria half is the one people skip. Me for a long while.