Home / Articles / Designing Frontend Components Around Responsibility, Not Reuse

This article is published in English.

Designing Frontend Components Around Responsibility, Not Reuse

Learn why organizing frontend components by clear ownership and responsibility—rather than maximum code reuse—makes feature changes easier to isolate and maintain.

3171 words

A frontend gets easier to maintain when component boundaries are drawn around clear responsibilities rather than around how much code can theoretically be shared.

The trouble with our frontend was never that individual components had grown too large. Most of them were genuinely compact.

We had a solid library of reusable buttons, cards, modals, table elements, form controls, hooks, API helpers, shared schemas, and tidy folder separation. Looking at the repository, everything appeared well organized. Reuse was abundant, obvious duplication was rare, and the level of abstraction gave the codebase an air of maturity.

The real issue surfaced whenever a feature needed to change.

Even a minor requirement could send us hunting through several unrelated layers at once: a screen-level component, a general-purpose form, some shared logic wrapped in a hook, a piece of networking code, a dialog meant for many use cases, and a schema used to validate input. A component that started out shared because two screens looked alike would slowly accumulate flags, callback props, conditional validation rules, alternate layouts, and special cases for workflows it was never meant to know about.

The code was reusable, yet responsibility was smeared across the system.

What ultimately made the frontend simpler wasn't a new folder naming scheme, a different state management library, or a strict line-count limit per component. It was a shift in what we expected a component boundary to actually represent.

Instead of only asking:

Can this component be reused elsewhere?

we began asking:

What responsibility should this component be responsible for?

And a second question quickly became just as central:

When that responsibility needs to change, how much unrelated code gets dragged along with it?

That question reshaped our architecture into a straightforward hierarchy, with the middle layer carrying the most weight.

Reusable primitives took care of presentation only. Feature components carried the understanding of business workflows. Pages and other composition boundaries decided how all the pieces came together.

Working on the frontend got easier, not because we removed all duplication, but because feature-level changes became more contained, and fewer parts of the codebase needed to understand each other.

1. Reuse Was Not the Same Thing as Simplicity

"Avoid duplicating components" is advice that generally sounds correct.

And frequently, it is.

When two screens share the same button, input field, or modal shell, unifying that implementation cuts down on inconsistency and extra upkeep.

Problems start when visual resemblance gets mistaken for shared responsibility.

Picture two separate features that both need a confirmation dialog.

The first implementation might look something like this:

<ConfirmationDialog
  title="Delete project?"
  onConfirm={deleteProject}
/>

Then a different workflow needs the same dialog, except without a cancel button.

A third case calls for a custom warning message.

Yet another needs to run an asynchronous check before the user can confirm.

Before long, the component evolves into something like this:

<ConfirmationDialog
  title="Delete project?"
  variant="danger"
  showCancel
  disableConfirm={isDeleting}
  customWarning={warning}
  onBeforeConfirm={validateDeletion}
  onConfirm={deleteProject}
  onSpecialAction={archiveInstead}
  useLegacyLayout={false}
/>

Technically, the component is still being reused.

But architecturally, it has drifted into acting as a configuration language.

Each new workflow asks the shared component to accommodate one more variation. It becomes more flexible, but that flexibility comes at the cost of packing more hidden behavior behind an ever-growing set of props.

This is precisely where the price of abstraction diverges from the price of duplication.

Duplication announces itself right away. Two near-identical components sit side by side in the repository, plainly visible to anyone reading the code.

A poorly chosen abstraction looks cheap at first glance. Its real cost only shows up later, once the features it serves start evolving in different directions.

Duplication costs you something you can see immediately. Choosing the wrong abstraction costs you something you only notice once the "similar" features stop changing in sync.

That realization changed how we evaluated reuse.

Repeated JSX no longer triggered automatic suspicion. The question that mattered more was whether two pieces of code truly shared a responsibility, or simply happened to resemble each other at that moment.

2. We Started Designing Components Around Responsibility

The most significant architectural shift was letting the component tree mirror actual responsibility instead of chasing reuse potential.

Take a settings workflow as an example.

Each layer in that workflow exists for its own distinct reason.

UserSettingsPage composes the feature into the rest of the application. It may be aware of routing, page-level layout, or which account is currently being edited.

UserSettingsForm carries the workflow knowledge. It understands what data belongs in user settings, how the fields relate to each other, what a submission means, and how errors ought to be shown to the user.

Button, Input, and Checkbox have no idea that user settings even exist. They stay reusable precisely because their responsibilities are genuinely general-purpose.

That middle feature layer is the one teams tend to lose first.

In a frontend that chases reuse too aggressively, developers often skip straight from a page to generic components, bypassing any feature-specific layer.

When that happens, business behavior no longer has an obvious home.

It ends up leaking into configuration instead.

The generic form starts knowing that one workflow needs a username field while another doesn't. The generic table starts knowing which actions are valid for a specific kind of domain object. A shared modal picks up special-case behavior simply because one workflow happens to render its content inside a modal.

The reusable layer gradually absorbs knowledge about the business it was never meant to carry.

Designing around responsibility reverses that pressure.

Feature-specific components are given permission to understand the feature they serve.

Reusable primitives are kept deliberately unaware of anything feature-specific.

That separation makes the whole architecture easier to reason about, because every layer only needs to hold onto a smaller amount of context.

3. Specific-Purpose Components Earned Their Place

One of the harder habits to break was assuming that a component used in only one spot represented a design flaw.

Take this example:

<OrderCancellationDialog />

The name alone tells you this component was built for a single workflow.

Compare that to something like:

<ConfirmationDialog />

This second name reads as more reusable.

Yet canceling an order can eventually demand far more than a simple confirmation.

The user might need to pick a cancellation reason. The screen might need to surface refund details. Certain orders might be ineligible for cancellation. Permission checks might vary. Warning copy might change depending on fulfillment status. The action itself might have its own async error handling.

Push all of that into a generic ConfirmationDialog, and the shared component slowly becomes an expert on what canceling an order actually means.

A better shape usually looks like this:

OrderCancellationDialog takes ownership of the entire workflow.

It's allowed to know about permissions, cancellation reasons, async status, refund text, and error states, because all of that naturally belongs together.

Meanwhile, the lower-level building blocks stay reusable precisely because they carry no knowledge of orders at all.

That split changed how many component decisions got made.

There was no longer a need to justify a component's existence by counting how many places imported it.

Reusability isn't a requirement for every component. Some components exist purely to give one piece of business logic a proper home.

A component with a single usage can still pull its weight if it makes a workflow easier to locate, understand, and modify later.

That kind of local clarity often outweighs the cost of forcing a second, unrelated workflow into a shared abstraction just because the surface APIs look similar.

4. Business Logic Stayed Out of Display Components

The same responsibility issue showed up again around data handling and infrastructure concerns.

A component that starts out purely visual can slowly pick up responsibilities like fetching data, reading URL parameters, running mutations, triggering notifications, handling navigation, managing cache, and tracking workflow state.

Picture a ProjectTable that eventually handles all of this:

ProjectTable
 ├── fetch projects
 ├── read query parameters
 ├── filter projects
 ├── manage loading state
 ├── render rows
 ├── delete projects
 ├── show notifications
 └── navigate after actions

The name still suggests "table."

But the component now understands a good chunk of the feature.

That makes it hard to reuse elsewhere, since reusing the table also drags along assumptions about fetching, mutations, navigation, caching, and side effects.

A structure organized around responsibility might look more like this:

The feature-level code decides what "loading" looks like, how errors get handled, what deletion actually does, which filters apply to this particular workflow, and whether the user should be redirected afterward.

ProjectTable itself just focuses on displaying project data and reporting user actions.

This doesn't mean presentation components have to be stripped of all logic.

Treating that as a hard rule just recreates the same issue in a different shape. A table can legitimately hold local interaction state, like which rows are expanded or which columns are visible, since that state genuinely belongs to the table.

The better guiding idea is:

Don't let infrastructure-level knowledge spread further down the component tree than the feature actually needs.

The point isn't to strip logic out of components entirely.

It's to keep logic close to whichever responsibility gives that logic its meaning.

5. Assembling Pieces Instead of Toggling Options

One of the most noticeable shifts happened when we stopped answering every new requirement with another prop.

Generic components have a tendency to grow through configuration.

A table might start out modestly:

<DataTable rows={projects} columns={columns} />

Then the requirements pile up:

<DataTable
  rows={projects}
  columns={columns}
  selectable
  sortable
  paginated
  editableRows
  showBulkActions
  enableExport
  customToolbar={toolbar}
  rowActions={rowActions}
  emptyState={emptyState}
  onSelectionChange={handleSelection}
/>

None of these props is unreasonable by itself.

The trouble starts once the component has to track which combinations of props are even valid.

Can rows be both editable and selectable at once?

Should bulk actions show up if export has been turned off?

Does a custom toolbar replace the default one, or sit alongside it?

Is pagination handled client-side or by the server?

Every added boolean and callback grows the number of states the generic component must account for.

Composition shifts some of that responsibility back onto the caller:

<Table>
  <TableToolbar>
    <ProjectFilters />
    <ExportButton />
  </TableToolbar>
<ProjectRows projects={projects} />
  <Pagination />
</Table>

Now it's the parent that decides which pieces this particular workflow actually requires.

The core table primitives don't need to bake in every product-specific variation the app might someday invent.

Configuration expects a component to anticipate every possible combination. Composition lets the caller build only the combination it actually needs.

Composition doesn't automatically stop someone from assembling something that doesn't make sense. A caller can still produce a broken combination.

What it does change is different: the shared component no longer has to encode every business-specific permutation on its own.

Some configuration is still perfectly fine to keep. A reusable Button, for instance, should support consistent options like size, disabled state, or visual emphasis.

The real question to ask is whether a given prop represents another variation of the same underlying responsibility, or whether it's quietly teaching one component to handle several unrelated jobs at once.

6. Clear Ownership Made State Decisions Simpler

Most frontend state problems look, on the surface, like tooling problems.

The conversation usually turns into a debate about mechanisms:

Should this live in component state, Context, Zustand, Redux, the URL, or a server cache?

In practice, though, many of these questions resolve themselves once you know who is actually responsible for the behavior.

State tends to drift upward whenever ownership is unclear:

A modal starts out with state that lives inside it.

Then some other component also needs to trigger it, so the state gets lifted.

Later, a toolbar far away in the tree needs access too, so it lands in Context.

Before long, a single global store is holding modal visibility flags, half-finished form drafts, table filter settings, cached API responses, active tab selections, and assorted details from features that have nothing to do with each other.

At that point people usually blame the state management library for the mess.

But the real issue is usually not the tool, it's ownership.

Once your component boundaries already reflect actual responsibilities, state naturally has somewhere sensible to live.

Whatever a user is currently typing into a field can stay local to that field.

A multi-step cancellation flow belongs inside the cancellation feature.

Anything fetched from the server belongs wherever your app handles server-state concerns.

A filter that needs to persist across navigation, or be shareable via a link, probably belongs in the URL.

State that is genuinely shared across many unrelated parts of the app may justify a broader, more centralized owner.

None of this makes hard state decisions disappear, but it gives you a framework for making them.

Once component boundaries reflect real ownership, deciding where state should live gets a lot more straightforward.

That mental model did more for the team than picking one "correct" state library ever could.

7. Sometimes Duplication Was the Better Deal

The hardest part of this approach to accept was that a certain amount of duplication is fine, even good.

Imagine two forms that look almost identical, sharing something like 80 percent of their markup and structure.

The instinct is to merge them into one shared component right away.

But that remaining 20 percent might encode completely different business logic.

Maybe one form validates differently.

Maybe submitting one triggers a different chain of effects than the other.

One might just save a draft.

The other might trigger something irreversible.

One form is likely to grow more complex over time, while the other should stay minimal.

Trying to force both into a single shared implementation tends to produce something that looks like a maze of conditionals and special cases baked into one "reusable" component.

At that point, you've traded duplicated JSX for duplicated cognitive load. Anyone touching either workflow now has to reverse-engineer how the shared component protects the other one before they can safely change anything.

In cases like this, it's often healthier to just keep two separate, named things:

FeatureAForm
FeatureBForm

even though some of the underlying code repeats itself.

This isn't an argument for duplication as a virtue in general.

Once a genuinely shared responsibility becomes obvious and stable, you can extract it. Both forms might eventually share the same input primitives, validation helpers, layout wrappers, or other domain-agnostic pieces.

The real difference is about timing, not principle.

Rather than reaching for abstraction the moment two things look alike, it made more sense to wait until a shared boundary had actually proven itself over time.

A rule of thumb emerged from this:

It's better to duplicate simple code than to share logic built on assumptions that are still evolving.

Plain duplicated code is easy to see and stays contained to one place.

An abstraction created too early, on the other hand, can quietly bundle together several feature-specific assumptions under one deceptively tidy name, hiding the very complexity you were trying to remove.

8. What This Was Really About: Keeping Change Local

Eventually it became clear that this whole approach was never really about how big or reusable a component was.

It was about how localized change could be.

The question worth asking every time was:

When we change one feature, how much unrelated code do we need to understand first?

A design with more shared, generalized components might technically have fewer duplicated lines than one with more feature-specific pieces.

But it can still force a developer to hold a much larger portion of the whole application in their head just to make one safe edit.

That kind of cost rarely shows up in any reuse metric.

You can have a beautifully reusable component that nonetheless creates a terrible boundary for making changes.

Conversely, a component built for exactly one feature and used in exactly one place can still improve the codebase's maintainability, simply because it keeps that one workflow self-contained and easy to reason about.

This ended up being the sharpest formulation of the whole idea:

Design component boundaries around local reasoning, not around maximizing reuse.

That single principle ties everything else together.

Reusable primitives earn their place because presentation patterns really do repeat across a codebase.

Feature-specific components stay specific because business workflows change for reasons that rarely line up with each other.

Composition keeps shared components from having to learn every possible business variation.

State settles near whatever behavior actually owns it.

And duplication becomes acceptable exactly when sharing code would tie together assumptions that are already drifting apart on their own.

The frontend gets simpler not because every component became smaller or more reusable, but because each one asks less of the person reading it.

The Right Component Boundary Is One You Can Explain When Something Changes

It's tempting to judge a frontend codebase by surface-level signals: heavy component reuse, minimal duplication, neatly organized folders, small file sizes. Those things aren't meaningless, but they say surprisingly little about what actually happens the moment a requirement changes.

That turned out to be the far more useful test to apply.

Where does this piece of behavior actually belong?

Which component is responsible for understanding this business rule?

Where should this particular piece of state live?

If a requirement changes, which files should logically move together?

Which parts of the UI are genuinely reusable, and which are meant to stay specific to one feature?

The pattern that ultimately simplified this frontend wasn't some clever new abstraction technique.

It came down to giving every layer of the codebase less to keep track of.

Reusable primitives were responsible for presentation.

Feature components were responsible for workflows.

Composition boundaries were responsible for how features fit together.

And business-specific components were given permission to stay exactly that: specific.

The resulting codebase didn't necessarily end up with fewer components, or the theoretical minimum amount of duplication.

What it did get was a codebase where a developer could work on a single feature without having to load the architecture of the entire frontend into their head first.

That turned out to be a far more practical definition of simplicity than counting components or duplicated lines ever was.

In your own experience, did more reusable components or clearer boundaries between existing components do more to make your frontend maintainable?