Skip to content

Components aren't use cases

A component is a presentation element.

It renders state. It accepts user interaction. It sends intents outward.

That sounds banal. It isn’t, though.

Because in a lot of frontend applications, components quietly turn into small application services with a template attached. They validate business rules, transform data, call APIs, decide on navigation, show toasts, update lists, and hold local state together on the side.

At that point, the component isn’t a component anymore.

It’s a use case wearing a component costume.

Components aren't use cases

A component is allowed to do UI work.

That means:

  • displaying data
  • consuming a ViewModel
  • holding local UI state
  • accepting user interactions
  • sending simple UI events
  • changing presentation based on state

A component is allowed to know whether a dialog is open. It’s allowed to know which tab is active. It’s allowed to render a form. It’s allowed to disable a button when the ViewModel says an action isn’t allowed.

But it shouldn’t decide for itself what has to happen, in business terms, when someone clicks that button.

That’s the decisive point.

This is often what the drift looks like:

@Component(...)
export class OrderComponent {
constructor(
private http: HttpClient,
private router: Router,
private toast: ToastService,
) {}
async submitOrder(data: OrderData) {
const validated = this.validate(data); // business logic
const enriched = this.enrich(validated); // domain transformation
await firstValueFrom(
this.http.post('/api/orders', enriched), // infrastructure
);
this.toast.success('Order saved'); // side effect
this.router.navigate(['/confirmation']); // navigation
}
private validate(data: OrderData) {
// business rules
}
private enrich(data: OrderData) {
// domain transformation
}
}

This looks productive at first.

Everything sits in one place. You can follow the flow. The button clicks, the method runs, done.

Except this is the wrong place.

The component now knows:

  • business validation rules
  • data enrichment
  • HTTP infrastructure
  • toast behavior
  • navigation
  • the use case’s sequence of steps

That’s not a presentation element anymore.

That’s a use case with an HTML attachment.

Service with a template

The problem isn’t that the component has “a lot of code.”

A lot of code is just a symptom.

The real problem is: it has too many reasons to change.

If the presentation changes, the component has to change. If the business rule changes, the component has to change. If the API changes, the component has to change. If navigation changes, the component has to change. If toast behavior changes, the component has to change.

That’s bad coupling.

Not because some architecture diagram is offended. But because changes end up in places where they’re expensive.

Components are often harder to test, more tightly bound to the framework, and closer to the DOM. That’s exactly the place where you don’t want to hide business workflows.

The question isn’t:

Is a component allowed to have a method?

Of course it’s allowed to.

The better question is:

Does this method make a business decision?

If yes, it probably doesn’t belong in the component.

A component method like this one is uncritical:

openDetails() {
this.detailsVisible.set(true);
}

That’s UI state.

A method like this one is suspicious:

submitOrder(data: OrderData) {
if (data.items.length === 0) {
this.toast.error('An order with no line items isn\'t allowed');
return;
}
const payload = this.mapToPayload(data);
this.http.post('/api/orders', payload).subscribe(() => {
this.router.navigate(['/confirmation']);
});
}

Business rule, mapping, infrastructure, and orchestration all happen here.

That’s not UI behavior anymore.

A robust component doesn’t spell out the entire flow.

It sends an intent.

@Component(...)
export class OrderComponent {
readonly vm = this.facade.vm;
constructor(private readonly facade: OrderFacade) {}
submit(data: OrderData) {
this.facade.submitOrder(data);
}
}

With that, the component only says:

The user wants to submit an order.

It doesn’t know whether an HTTP call happens afterward. It doesn’t know whether a toast appears. It doesn’t know whether navigation happens. It doesn’t know whether a reload gets triggered.

And that’s exactly the point.

Intent instead of orchestration: button click → submitOrder intent → facade/use case → result → UI renders a new ViewModel. The component has only two arrows: ViewModel in, intent out.

Not the component.

Depending on the architecture, that can be a facade, a store, a use-case service, or an application layer.

The name matters less than the responsibility.

Component
-> sends intent
Facade / Store
-> coordinates state and UI-adjacent flows
Use Case / Application Service
-> executes the business workflow
Infrastructure
-> talks to the API, storage, external systems

Or, more compactly:

Component → Facade → Use Case → Infrastructure

The component is the edge of the system.

It’s not the system.

As a rule of thumb, I’d keep these things out of components:

  • HTTP calls
  • repository access
  • DTO mapping
  • business validation
  • domain transformations
  • complex calculations
  • event orchestration
  • reload chains
  • toast and navigation decisions as part of a use case
  • manual synchronization of multiple data sources
  • permission logic that goes beyond simple display decisions

Not every single violation is an immediate disaster.

But when several of these things pile up together, the component has probably outgrown its role.

Responsibility

Not everything has to be extracted.

Otherwise you just get architecture theater.

Allowed to stay in components:

  • local visibility of a dialog
  • the active tab
  • hover/focus states
  • simple UI interaction
  • forwarding a button click
  • consuming a ViewModel
  • very simple presentation helpers

Example:

@Component(...)
export class OrderDialogComponent {
readonly dialogVisible = signal(false);
open() {
this.dialogVisible.set(true);
}
close() {
this.dialogVisible.set(false);
}
submit(formValue: OrderFormValue) {
this.submitted.emit(formValue);
}
}

That’s UI behavior.

The component doesn’t decide whether the order is valid in business terms, what the API payload looks like, or where to navigate after success.

When use cases live inside components, tests have to go through the UI layer.

Then suddenly you’re testing the DOM, framework lifecycle, service mocks, the router, toasts, and business rules all in one test.

That’s cumbersome.

When components only render ViewModels and send intents, tests get smaller:

  • Component tests check presentation.
  • Use-case tests check business workflows.
  • Mapper tests check translation.
  • Store/facade tests check state and event flows.

Each layer gets tested where its responsibility lies.

This isn’t an end in itself. It makes tests faster, more stable, and more readable.

A component is allowed to trigger an interaction.

But it shouldn’t own the entire business workflow.

That’s the difference between:

submit(data) {
this.facade.submitOrder(data);
}

and:

submit(data) {
const validated = this.validate(data);
const payload = this.map(validated);
this.http.post('/api/orders', payload).subscribe(() => {
this.toast.success('Saved');
this.router.navigate(['/confirmation']);
this.reload();
});
}

In the first case, the component expresses an intent.

In the second case, it owns the use case.

Components aren’t use cases.

They’re the edge toward the UI.

They render state. They accept interactions. They send intents.

But they don’t orchestrate business logic, infrastructure, or business workflows.

A good component is almost boring:

ViewModel in.
Intent out.
Rendering in between.

That’s not less architecture.

That’s exactly the point of architecture.