DEV Community

Cover image for ๐Ÿ’กSmart vs Presentational Components in Angular 2026: Where Does Ownership Really Belong?
abdelaaziz ouakala
abdelaaziz ouakala

Posted on

๐Ÿ’กSmart vs Presentational Components in Angular 2026: Where Does Ownership Really Belong?

"Smart vs Presentational components didn't die. Angular just changed where the 'smartness' belongs."

Large Angular codebases in 2026 reveal a clear truth: the pattern that kills scalability fastest isnโ€™t โ€œtoo many componentsโ€ โ€”itโ€™s
unclear ownership.

Hereโ€™s whatโ€™s actually changed this year ๐Ÿ‘‡.

Every team I've worked with that struggled to maintain their Angular app had the same root problem: nobody could answer "who owns this state?" cleanly. The components were there. The services were there. But the boundaries were invisible, undocumented, and violated constantly.

This is the definitive 2026 breakdown of Smart vs Presentational component architecture โ€” updated for Signals, standalone APIs, and the enterprise Angular teams shipping production code today.


Table of Contents

  1. Why This Pattern Still Matters in 2026
  2. The Old World: What We Got Wrong
  3. What Signals Actually Changed
  4. Presentational Components: The New Definition
  5. Smart Components: The Orchestration Layer
  6. The Real Question: Who Owns the State?
  7. Component Boundaries as Contracts
  8. The Signal() Decision Framework
  9. Code: The 2026 Pattern in Practice
  10. Anti-Patterns Still Destroying Enterprise Apps
  11. Enterprise Scalability Reasoning
  12. Feature Architecture and the Module Boundary Question
  13. Performance Implications
  14. Testing Strategy for Both Patterns
  15. Team Scalability and Cognitive Load
  16. The Contrarian Take
  17. Summary: The 2026 Mental Model

Why This Pattern Still Matters in 2026 {#why-this-pattern-still-matters}

In 2019, the Smart vs Presentational (also called Container vs Presentational, or Smart vs Dumb) debate felt settled. We had NgRx, we had async pipes, and we understood that containers fetch data while dumb components just render it.

Then Angular evolved fast:

  • Angular 14 introduced standalone components โ€” no more NgModule for every component
  • Angular 16 shipped Signals as developer preview
  • Angular 17 stabilized signal(), computed(), effect(), signal-based input() and output()
  • Angular 18โ€“19 matured the full reactive primitive system, with linkedSignal(), resource(), and server-side rendering improvements

Every one of these changes touched component responsibilities. And yet the fundamental question โ€” who renders, who orchestrates, who owns the state โ€” didn't disappear. It just became more important to answer correctly.

The teams that ignored this ended up with something worse than the "container monster" of the NgRx era: components that use Signals and inject services and handle routing and render UI โ€” all while calling themselves "modern Angular."

Modern syntax does not equal modern architecture. That's the trap.


The Old World: What We Got Wrong {#the-old-world}

Let me describe a codebase I reviewed in 2022. It was a mid-size enterprise Angular app, well-intentioned, with 40+ developers contributing over 3 years.

The architecture looked correct on paper:

/features
  /user-management
    user-management.container.ts   โ† "smart"
    user-list.component.ts         โ† "presentational"
    user-card.component.ts         โ† "presentational"
    user-filter.component.ts       โ† "presentational"
Enter fullscreen mode Exit fullscreen mode

The reality inside those files was different:

user-management.container.ts โ€” 1,100 lines. It fetched users, managed filters, handled pagination, subscribed to auth state, responded to router events, and passed 20+ inputs down to children. It had 14 injected dependencies. No one on the team could touch it without breaking something.

user-card.component.ts โ€” Labeled "presentational." Injected AuthService to conditionally show a "delete" button. Had a local BehaviorSubject for hover state. Emitted a custom event but also directly called this.router.navigate() under certain conditions.

The labels were there. The separation wasn't.

What went wrong? The old mental model conflated size with responsibility. The container was "smart" because it was big. The card was "presentational" because it was small. Nobody enforced actual boundaries, and violation was invisible โ€” just a service injection here, a router call there, until the architecture collapsed under its own weight.


What Signals Actually Changed {#what-signals-changed}

Signals didn't eliminate the Smart vs Presentational pattern. They clarified it.

Here's what changed:

Before Signals: State Forced Centralization

Without Signals, local reactive state was painful. You had three choices for component-level reactivity:

  1. BehaviorSubject โ€” verbose, manual subscription management, easy to leak
  2. async pipe โ€” clean, but only works in templates, no imperative access
  3. Pass everything from a parent container โ€” which inflated containers

This meant components that should be simple ended up either leaking state or depending on a parent container to manage trivial UI state (an open/closed accordion, a hover state, a selected tab). The container grew. The "dumb" component stayed small but had invisible wiring dependencies.

After Signals: Local State Became Cheap

With signal(), a component can own its own ephemeral reactive state without any of that overhead:

// Before Signals โ€” accordion toggle required BehaviorSubject or parent coordination
private _isOpen$ = new BehaviorSubject<boolean>(false);
isOpen$ = this._isOpen$.asObservable();
toggle() { this._isOpen$.next(!this._isOpen$.value); }

// After Signals โ€” trivial, readable, zero subscription management
isOpen = signal(false);
toggle() { this.isOpen.update(v => !v); }
Enter fullscreen mode Exit fullscreen mode

This removed a whole class of "smart container needed here just to manage local state." Which means smart components can now focus on what they were always supposed to do: orchestrate business flows, not babysit UI toggle state.

What computed() Changed

Before: derived state meant a chain of combineLatest() operators, manual typing, and cognitive overhead.

After: derived state is a single, readable, synchronously-evaluated expression that Angular tracks automatically:

// Before
filteredUsers$ = combineLatest([this.users$, this.filter$]).pipe(
  map(([users, filter]) => users.filter(u => u.name.includes(filter)))
);

// After
filteredUsers = computed(() =>
  this.users()?.filter(u => u.name.includes(this.filter())) ?? []
);
Enter fullscreen mode Exit fullscreen mode

The logic is identical. The ceremony is gone. And critically: Angular's fine-grained reactivity means only components that read filteredUsers re-render when it changes โ€” not the whole tree.

What Signal-Based Inputs Changed

Angular 17+ introduced input() as a signal-based replacement for @Input(). This is subtle but architecturally significant:

// Old decorator-based input
@Input() user!: User;

// New signal-based input โ€” readable as a signal in the template and class
user = input.required<User>();

// In the template: {{ user().name }}
// In the class: const name = this.user().name; // synchronous, no subscription
Enter fullscreen mode Exit fullscreen mode

Presentational components can now derive state from their inputs reactively and locally, without any parent coordination:

@Component({ standalone: true, changeDetection: ChangeDetectionStrategy.OnPush })
export class UserCardComponent {
  user = input.required<User>();
  isAdmin = input<boolean>(false);

  // Derived from inputs โ€” no service, no parent, no subscription
  displayName = computed(() =>
    this.isAdmin() ? `[Admin] ${this.user().name}` : this.user().name
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the Signals-era presentational component. It derives display logic from its inputs. It never reaches outside its own boundary.


Presentational Components: The New Definition {#presentational-components}

The 2026 definition of a presentational component isn't "small" or "dumb." It's about the contract it enforces.

The Contract

"Give me my data through inputs. I will render it. Tell me via outputs when something happens that you should know about."

That's it. That's the entire contract.

A presentational component:

  • Receives data through input() or @Input()
  • Emits events through output() or @Output()
  • May hold local UI-only state via signal() (open/closed, hover, selected tab within a group)
  • Derives display logic via computed() from its own inputs
  • Never injects a service
  • Never calls the router
  • Never triggers a side effect that escapes the component
  • Is fully testable with only input data โ€” no service mocks needed

What "Presentational" Means for Your Design System

Presentational components are the atoms and molecules of your UI system. They include:

  • Buttons โ€” variants, states, loading indicators
  • Form inputs โ€” validation display, label, error state
  • Cards โ€” layout, header/body/footer slots
  • Tables โ€” column rendering, sort indicators, pagination display (not pagination logic)
  • Modals โ€” open/close state, header/body/footer structure
  • Data visualizations โ€” chart rendering from data passed as inputs
  • Navigation items โ€” active state, icon, label
  • Status badges โ€” color coding, label display

Notice: none of these need to know where the data comes from, how it was fetched, or what happens when the user's event is processed. That's not their job.

The Full 2026 Presentational Component Pattern

import {
  Component,
  ChangeDetectionStrategy,
  input,
  output,
  computed
} from '@angular/core';
import { CommonModule } from '@angular/common';

export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'member' | 'viewer';
  isActive: boolean;
}

@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="user-card" [class.inactive]="!user().isActive">
      <div class="user-card__header">
        <span class="user-card__name">{{ displayName() }}</span>
        <span class="user-card__badge" [attr.data-role]="user().role">
          {{ user().role }}
        </span>
      </div>
      <div class="user-card__body">
        <span class="user-card__email">{{ user().email }}</span>
      </div>
      <div class="user-card__actions">
        <button
          (click)="select.emit(user())"
          [disabled]="!user().isActive"
        >
          View Profile
        </button>
        @if (canDelete()) {
          <button class="danger" (click)="delete.emit(user().id)">
            Remove
          </button>
        }
      </div>
    </div>
  `
})
export class UserCardComponent {
  // โ”€โ”€ Inputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  user       = input.required<User>();
  showDelete = input<boolean>(false);

  // โ”€โ”€ Outputs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  select = output<User>();
  delete = output<string>();

  // โ”€โ”€ Local derived state (computed from inputs only) โ”€โ”€โ”€
  displayName = computed(() => {
    const u = this.user();
    return u.isActive ? u.name : `${u.name} (Inactive)`;
  });

  canDelete = computed(() => this.showDelete() && this.user().isActive);

  // โ”€โ”€ Zero injected dependencies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  // This component can be tested with zero mocks.
}
Enter fullscreen mode Exit fullscreen mode

Notice what's absent: no inject(), no constructor() with dependencies, no service calls, no router. The component is a pure function of its inputs. You can test it by instantiating it with different input values and asserting on the rendered output. That's the power of this contract.


Smart Components: The Orchestration Layer {#smart-components}

The modern name for "smart component" in enterprise Angular is the feature shell or feature orchestrator. The label matters less than the responsibility.

The Contract

"I own this feature flow. I decide what data gets loaded. I coordinate business events. I delegate all rendering to presentational children."

A smart component / feature shell:

  • Injects services for data access, routing, auth
  • Owns feature-level state via signal() and computed()
  • Converts observables to signals with toSignal()
  • Responds to child component events and processes them as business actions
  • Never renders data directly โ€” delegates all rendering to presentational components
  • Is the entry point for a routed feature

Smart Component Size vs Responsibility

One misconception that persists: smart components should be small. Wrong. Smart components can be large โ€” because orchestrating a feature is genuinely complex. The size isn't the problem. Mixed concerns are the problem.

A 300-line smart component that only orchestrates is fine. A 50-line "presentational" component that injects a service is not.

The Full 2026 Smart / Feature Shell Pattern

import {
  Component,
  ChangeDetectionStrategy,
  inject,
  signal,
  computed
} from '@angular/core';
import { Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';

import { UserService } from './data-access/user.service';
import { AuthStore } from '../auth/auth.store';
import { UserCardComponent } from './ui/user-card.component';
import { UserFiltersComponent } from './ui/user-filters.component';
import { UserTableComponent } from './ui/user-table.component';
import { User } from './models/user.model';

@Component({
  selector: 'app-user-management',
  standalone: true,
  imports: [
    UserCardComponent,
    UserFiltersComponent,
    UserTableComponent
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="feature-shell">
      <app-user-filters
        [activeFilter]="activeFilter()"
        (filterChange)="onFilterChange($event)"
      />

      @if (isLoading()) {
        <div class="loading-state">Loading users...</div>
      } @else if (error()) {
        <div class="error-state">{{ error() }}</div>
      } @else {
        <app-user-table
          [users]="filteredUsers()"
          [currentUserRole]="currentUserRole()"
          (userSelected)="onUserSelect($event)"
          (userDeleted)="onUserDelete($event)"
        />
      }
    </div>
  `
})
export class UserManagementComponent {
  // โ”€โ”€ Dependencies (injected here โ€” NOT in presentational children) โ”€โ”€
  private userSvc  = inject(UserService);
  private authStore = inject(AuthStore);
  private router   = inject(Router);

  // โ”€โ”€ Feature state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  activeFilter  = signal<string>('');
  private _error = signal<string | null>(null);

  // โ”€โ”€ Data from services, converted to signals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  private users = toSignal(this.userSvc.getAll(), { initialValue: [] });
  isLoading     = toSignal(this.userSvc.loading$, { initialValue: false });
  error         = this._error.asReadonly();

  // โ”€โ”€ Derived / computed state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  filteredUsers = computed(() => {
    const term = this.activeFilter().toLowerCase();
    return term
      ? this.users().filter(u =>
          u.name.toLowerCase().includes(term) ||
          u.email.toLowerCase().includes(term)
        )
      : this.users();
  });

  currentUserRole = computed(() => this.authStore.user()?.role ?? 'viewer');

  // โ”€โ”€ Business event handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  onFilterChange(term: string): void {
    this.activeFilter.set(term);
  }

  onUserSelect(user: User): void {
    this.router.navigate(['/users', user.id]);
  }

  async onUserDelete(userId: string): Promise<void> {
    try {
      await this.userSvc.delete(userId);
    } catch (err) {
      this._error.set('Failed to delete user. Please try again.');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Study what this component does and doesn't do:

Does: inject services, own state, compute derived data, respond to business events, navigate on selection, handle errors

Doesn't: render a single <tr>, <td>, user name, email, or any data-display HTML directly

That's the boundary. And it's enforced in the template itself โ€” every rendered element is a component call, not raw HTML data display.


The Real Question: Who Owns the State? {#who-owns-the-state}

Here's the architectural question that replaces "is this smart or presentational?":

"Who should own this state โ€” and why?"

There are three answers:

1. Local signal() โ€” UI-only ephemeral state

Use local signal() when:

  • The state is purely about UI appearance (open/closed, hover, selected tab)
  • No other component needs to know about it
  • It doesn't affect business logic
  • It resets naturally when the component is destroyed
// โœ… Correct use of local signal โ€” UI toggle state
export class AccordionSectionComponent {
  label    = input.required<string>();
  isOpen   = signal(false);
  toggle() { this.isOpen.update(v => !v); }
}
Enter fullscreen mode Exit fullscreen mode

2. Feature-level signal() in a smart component โ€” shared feature state

Use smart-component-owned signals when:

  • Multiple presentational components in the same feature read the state
  • The state is derived from service data
  • The state has business significance (selected entity, active filter, loading status)
// โœ… Feature-level state โ€” owned by the smart orchestration layer
export class ProductSearchComponent {
  private productSvc = inject(ProductService);
  searchTerm    = signal('');
  selectedId    = signal<string | null>(null);
  products      = toSignal(this.productSvc.getAll(), { initialValue: [] });
  filtered      = computed(() =>
    this.products().filter(p =>
      p.name.toLowerCase().includes(this.searchTerm().toLowerCase())
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

3. A global store โ€” cross-feature shared state

Use a store (NgRx Signals Store, Elf, or a custom Injectable signal store) when:

  • The state is shared across multiple features
  • The state needs to survive navigation (shopping cart, user preferences, notifications)
  • Multiple smart components need to sync on the same state
// โœ… Cross-feature state โ€” injectable signal store
@Injectable({ providedIn: 'root' })
export class CartStore {
  private items = signal<CartItem[]>([]);

  readonly cartItems = this.items.asReadonly();
  readonly itemCount = computed(() => this.items().length);
  readonly total     = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.qty, 0)
  );

  addItem(item: CartItem)    { this.items.update(items => [...items, item]); }
  removeItem(id: string)     { this.items.update(items => items.filter(i => i.id !== id)); }
  clear()                    { this.items.set([]); }
}
Enter fullscreen mode Exit fullscreen mode

The Ownership Decision Tree

Is this state about UI appearance only?
  โ”œโ”€โ”€ Yes โ†’ local signal() in the component
  โ””โ”€โ”€ No โ†’ Does it belong to one feature?
        โ”œโ”€โ”€ Yes โ†’ signal() in the feature's smart component
        โ””โ”€โ”€ No โ†’ Injectable signal store (or NgRx Signals store)
Enter fullscreen mode Exit fullscreen mode

When in doubt, push state up one level. It's far easier to move state down (by passing it as inputs) than to extract entangled state out of a deep component tree later.


Component Boundaries as Contracts {#component-boundaries-as-contracts}

This is the senior-developer insight that transforms how you think about component architecture:

A component boundary is a communication contract, not a folder name.

What does that mean in practice?

When you define a component's input() and output() signatures, you're defining an API. That API:

  • Can be enforced through TypeScript (input.required<T>() means no default โ€” callers must provide it)
  • Can be tested independently with zero mocks (if presentational) or targeted mocks (if smart)
  • Can be documented through type signatures alone
  • Can be refactored without affecting other components โ€” as long as the public API stays stable

The moment a "presentational" component injects a service, it has broken its contract. It now has an invisible dependency that doesn't appear in its inputs and outputs. Callers can't see it. Tests can't control it. Refactors break unpredictably.

Enforcing Boundaries with Architecture Lint Rules

In enterprise teams, verbal conventions don't scale. You need tools.

With eslint-plugin-angular, you can write custom rules. Or you can enforce patterns manually through review guidelines:

Convention: Presentational components live in /ui folders. Smart components live at the feature root.

/features
  /user-management
    user-management.component.ts          โ† smart (feature shell)
    /ui
      user-card.component.ts              โ† presentational
      user-table.component.ts             โ† presentational
      user-filters.component.ts           โ† presentational
    /data-access
      user.service.ts
    /models
      user.model.ts
Enter fullscreen mode Exit fullscreen mode

The convention itself communicates intent. A UserCardComponent inside /ui that injects a service is visibly wrong in a code review โ€” because the folder declares its contract.


The signal() Decision Framework {#signal-decision-framework}

With Signals matured, here's a practical framework I use when reviewing Angular code:

Ask These Questions for Every signal() You See

1. Where is this signal declared?

  • In a presentational component (/ui folder) โ†’ it must be UI-only state. If it derives from service data, that's a violation.
  • In a smart component (feature shell) โ†’ it can own feature business state. Check if it belongs in a store instead.
  • In an @Injectable store โ†’ it's intentionally shared. Confirm the scope (providedIn: 'root' vs feature-provided).

2. Who reads this signal?

  • Only the declaring component's template โ†’ local signal, correct.
  • Another component in the same feature โ†’ feature-level state, should be in the smart component.
  • Components across features โ†’ global store, should be in an injectable service/store.

3. Does this signal have business significance?

  • If the value of this signal would matter to a backend API, a business rule, or a cross-component flow โ†’ it does not belong in a presentational component.
  • If it's purely about which CSS class to apply or whether a dropdown is open โ†’ local signal is correct.

4. What happens to this signal when the component is destroyed?

  • If the answer is "it resets and nobody cares" โ†’ local signal is fine.
  • If the answer is "the user loses their progress/selection" โ†’ the state belongs in a parent feature or store.

Code: The 2026 Pattern in Practice {#code-examples}

Let's walk through a complete, real-world-adjacent feature: a product search and selection flow.

Feature Structure

/products
  product-search.component.ts       โ† smart (feature shell)
  /ui
    product-search-bar.component.ts  โ† presentational
    product-list.component.ts        โ† presentational
    product-card.component.ts        โ† presentational
    product-detail-panel.component.ts โ† presentational
  /data-access
    product.service.ts
  /models
    product.model.ts
Enter fullscreen mode Exit fullscreen mode

The Presentational Components

// product-search-bar.component.ts
@Component({
  selector: 'app-product-search-bar',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input
      type="search"
      [value]="value()"
      (input)="searchChange.emit($any($event.target).value)"
      placeholder="Search products..."
    />
  `
})
export class ProductSearchBarComponent {
  value        = input<string>('');
  searchChange = output<string>();
  // Zero injected services. Zero side effects. Pure I/O.
}
Enter fullscreen mode Exit fullscreen mode
// product-card.component.ts
@Component({
  selector: 'app-product-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="product-card" [class.selected]="isSelected()">
      <img [src]="product().imageUrl" [alt]="product().name" />
      <div class="product-card__info">
        <h3>{{ product().name }}</h3>
        <span class="price">{{ formattedPrice() }}</span>
        <span class="stock" [class.low]="isLowStock()">
          {{ stockLabel() }}
        </span>
      </div>
      <button (click)="select.emit(product())">
        {{ isSelected() ? 'Selected' : 'Select' }}
      </button>
    </div>
  `
})
export class ProductCardComponent {
  product    = input.required<Product>();
  isSelected = input<boolean>(false);
  select     = output<Product>();

  // All derived from inputs โ€” no external dependencies
  formattedPrice = computed(() =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
      .format(this.product().price)
  );

  isLowStock  = computed(() => this.product().stock < 10);
  stockLabel  = computed(() =>
    this.isLowStock()
      ? `Only ${this.product().stock} left`
      : `In stock (${this.product().stock})`
  );
}
Enter fullscreen mode Exit fullscreen mode

The Smart Feature Shell

// product-search.component.ts
@Component({
  selector: 'app-product-search',
  standalone: true,
  imports: [
    ProductSearchBarComponent,
    ProductListComponent,
    ProductDetailPanelComponent
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="product-search-feature">
      <app-product-search-bar
        [value]="searchTerm()"
        (searchChange)="onSearch($event)"
      />

      <div class="product-search-feature__layout">
        <app-product-list
          [products]="filteredProducts()"
          [selectedId]="selectedProductId()"
          [isLoading]="isLoading()"
          (productSelected)="onProductSelect($event)"
        />

        @if (selectedProduct()) {
          <app-product-detail-panel
            [product]="selectedProduct()!"
            (addToCart)="onAddToCart($event)"
            (close)="onClose()"
          />
        }
      </div>
    </div>
  `
})
export class ProductSearchComponent {
  private productSvc = inject(ProductService);
  private cartStore  = inject(CartStore);
  private analytics  = inject(AnalyticsService);

  // โ”€โ”€ State owned by this feature shell โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  searchTerm        = signal('');
  selectedProductId = signal<string | null>(null);

  // โ”€โ”€ Data from services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  private allProducts = toSignal(this.productSvc.getAll(), { initialValue: [] });
  isLoading           = toSignal(this.productSvc.loading$, { initialValue: false });

  // โ”€โ”€ Derived state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  filteredProducts = computed(() => {
    const term = this.searchTerm().toLowerCase().trim();
    if (!term) return this.allProducts();
    return this.allProducts().filter(p =>
      p.name.toLowerCase().includes(term) ||
      p.description.toLowerCase().includes(term)
    );
  });

  selectedProduct = computed(() =>
    this.allProducts().find(p => p.id === this.selectedProductId()) ?? null
  );

  // โ”€โ”€ Business event handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  onSearch(term: string): void {
    this.searchTerm.set(term);
    this.selectedProductId.set(null); // clear selection on new search
  }

  onProductSelect(product: Product): void {
    this.selectedProductId.set(product.id);
    this.analytics.track('product_viewed', { productId: product.id });
  }

  onAddToCart(product: Product): void {
    this.cartStore.addItem({ ...product, qty: 1 });
    this.analytics.track('product_added_to_cart', { productId: product.id });
    this.selectedProductId.set(null);
  }

  onClose(): void {
    this.selectedProductId.set(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

Read this template carefully. Every element in it is a component call. No raw data rendering. The smart component's template is essentially a wiring diagram โ€” it maps data flows and event responses between the feature state and presentational children.

That's intentional. The template of a smart component should read like an architecture diagram.


Anti-Patterns Still Destroying Enterprise Apps {#anti-patterns}

These are the violations I find most often in production Angular codebases in 2026:

Anti-Pattern 1: The Service-Injecting "Presentational" Component

// โŒ This is not a presentational component
@Component({ selector: 'app-user-card', standalone: true })
export class UserCardComponent {
  user    = input.required<User>();
  private authSvc = inject(AuthService);  // ๐Ÿšจ Contract violation

  // Now this component's behavior depends on auth state
  // It can't be tested without mocking AuthService
  // It can't be reused in any context where AuthService isn't relevant
  canEdit = computed(() => this.authSvc.currentUser()?.role === 'admin');
}
Enter fullscreen mode Exit fullscreen mode

The fix: Pass canEdit as an input. Let the smart parent compute it. The card renders. The parent decides.

// โœ… Correct
export class UserCardComponent {
  user    = input.required<User>();
  canEdit = input<boolean>(false);  // Parent decides this, not the card
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 2: Routing from a Presentational Component

// โŒ Presentational component navigating
@Component({ selector: 'app-product-card', standalone: true })
export class ProductCardComponent {
  product = input.required<Product>();
  private router = inject(Router);  // ๐Ÿšจ Side-effect leak

  viewDetails(): void {
    this.router.navigate(['/products', this.product().id]);
  }
}
Enter fullscreen mode Exit fullscreen mode

The fix: Emit the event. Let the smart parent decide what to do with it.

// โœ… Correct
export class ProductCardComponent {
  product     = input.required<Product>();
  viewDetails = output<Product>();  // Parent handles navigation
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 3: Business Logic in computed() Inside a Presentational Component

// โŒ Business rule in a presentational component
export class OrderCardComponent {
  order = input.required<Order>();

  // ๐Ÿšจ "Discount eligibility" is a business rule, not a display rule
  isEligibleForDiscount = computed(() =>
    this.order().total > 100 &&
    this.order().status === 'pending' &&
    this.order().createdAt > someBusinessDate
  );
}
Enter fullscreen mode Exit fullscreen mode

The fix: Compute business rules in the smart parent or service. Pass the result as an input.

// โœ… Correct
export class OrderCardComponent {
  order              = input.required<Order>();
  showDiscountBadge  = input<boolean>(false);  // Smart parent computed this
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 4: The Feature-Spanning Presentational Component

This one is subtle. A "presentational" component that's used across multiple features but slowly accumulates feature-specific logic:

// โŒ "Shared" component that knows too much
@Component({ selector: 'app-data-table', standalone: true })
export class DataTableComponent {
  // Started as a pure table renderer...
  rows    = input.required<any[]>();
  columns = input.required<Column[]>();

  // ...then someone added feature-specific logic:
  private userSvc    = inject(UserService);    // ๐Ÿšจ
  private reportsSvc = inject(ReportsService); // ๐Ÿšจ
  private exportSvc  = inject(ExportService);  // ๐Ÿšจ
}
Enter fullscreen mode Exit fullscreen mode

The fix: Keep shared UI components pure. Provide feature-specific behavior through composition โ€” wrap the shared component inside a feature-specific smart component that handles the services.

Anti-Pattern 5: Mixing signal() and BehaviorSubject Without Reason

In 2026, there's no architectural reason to use BehaviorSubject for new code in a standalone Angular app. The mixed reactive model creates cognitive overhead and inconsistency. Migrate to Signals systematically:

// โŒ Mixed reactive model โ€” 2022 tech in 2026 code
export class FeatureComponent {
  private _filter$ = new BehaviorSubject<string>('');
  filter$          = this._filter$.asObservable();
  selectedId       = signal<string | null>(null);  // Some Signals

  // Now you need both pipe operators AND computed()
  // to derive combined state. Ugly.
}

// โœ… Consistent Signals approach
export class FeatureComponent {
  filter     = signal('');
  selectedId = signal<string | null>(null);

  // All derivations use computed() โ€” consistent, readable
}
Enter fullscreen mode Exit fullscreen mode

Enterprise Scalability Reasoning {#enterprise-scalability}

Why does this pattern matter at scale? Here's the reasoning I give to engineering managers and CTOs.

Cognitive Load Reduction

When every component in a folder has a clear, enforced type โ€” presentational OR smart โ€” developers new to the codebase can understand what a component does before opening the file.

/ui/user-card.component.ts โ†’ rendering only, inputs/outputs, no surprises
user-management.component.ts โ†’ orchestration, services, state management

The folder structure communicates architecture. This reduces onboarding time and code review surface area.

Parallel Development

Presentational components that follow the contract can be built and reviewed in isolation, in parallel with feature development. Your design system team can build and ship UserCardComponent without waiting for the UserService to exist. The smart component composes them when both are ready.

This is how enterprise frontend teams ship faster โ€” not by writing less code, but by working in parallel with clear, enforced boundaries.

Testability at Scale

Presentational component testing is trivially fast:

// โœ… Testing a presentational component โ€” zero mocks needed
it('should display inactive label when user is inactive', () => {
  const fixture = TestBed.createComponent(UserCardComponent);
  fixture.componentInstance.user.set({ ...mockUser, isActive: false });
  fixture.detectChanges();
  expect(fixture.nativeElement.textContent).toContain('Inactive');
});
Enter fullscreen mode Exit fullscreen mode

Smart component testing focuses on business logic, using mocked services:

// โœ… Testing a smart component โ€” mock the services, not the UI
it('should navigate to user profile on selection', () => {
  const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
  const component = new UserManagementComponent();
  // ... inject mocks
  component.onUserSelect(mockUser);
  expect(routerSpy.navigate).toHaveBeenCalledWith(['/users', mockUser.id]);
});
Enter fullscreen mode Exit fullscreen mode

At 100+ components, this separation means your test suite stays fast. Slow tests kill developer velocity. Slow developer velocity kills product delivery.

Refactoring Safety

When a presentational component has no injected dependencies, refactoring it is safe. You change the template, the inputs, the derived computeds โ€” the blast radius is contained to its callers.

When a smart component changes its orchestration logic, the impact is contained to the feature. Presentational components used by that feature don't change.

At enterprise scale, containment is a feature.


Feature Architecture and the Module Boundary Question {#feature-architecture}

With standalone components eliminating the forced NgModule ceremony, the module question becomes architectural rather than syntactical.

Feature Boundaries with Standalone Components

/src/app
  /features
    /users                              โ† Feature boundary
      index.ts                          โ† Public API โ€” what this feature exports
      user-management.routes.ts         โ† Lazy-loaded routes
      user-management.component.ts      โ† Feature shell (smart)
      /ui                               โ† Private โ€” only used within this feature
        user-card.component.ts
        user-table.component.ts
      /data-access                      โ† Feature services
        user.service.ts
    /products                           โ† Another feature boundary
      index.ts
      ...
  /shared                               โ† Shared presentational components
    /ui
      button.component.ts
      data-table.component.ts
      modal.component.ts
    /utils
      ...
Enter fullscreen mode Exit fullscreen mode

The index.ts barrel exports define the feature's public API. Anything not exported from index.ts is considered internal to the feature and shouldn't be imported by other features.

This is a convention, not a hard compiler enforcement โ€” but with ESLint rules from Sheriff or Nx module boundary rules, you can make it enforceable.

Injectable Stores as the Bridge

For state shared between features without NgRx's ceremony, the 2026 pattern is an @Injectable signal store:

// /shared/stores/notification.store.ts
@Injectable({ providedIn: 'root' })
export class NotificationStore {
  private _notifications = signal<Notification[]>([]);

  readonly all    = this._notifications.asReadonly();
  readonly unread = computed(() => this._notifications().filter(n => !n.isRead));
  readonly count  = computed(() => this.unread().length);

  push(notification: Notification): void {
    this._notifications.update(list => [notification, ...list]);
  }

  markRead(id: string): void {
    this._notifications.update(list =>
      list.map(n => n.id === id ? { ...n, isRead: true } : n)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Any smart component anywhere can inject this store and read from it reactively. The signal-based reactivity means components only re-render when the specific signal they read changes โ€” no unnecessary re-renders from unrelated state updates.


Performance Implications {#performance-implications}

The Smart vs Presentational architecture isn't just about code organization โ€” it has direct performance implications in Signals-era Angular.

OnPush + Signals = Maximum Granularity

All presentational components should use ChangeDetectionStrategy.OnPush. With signal-based inputs, Angular can skip change detection for a component entirely if none of its signal dependencies changed.

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush  // Always for presentational
})
export class UserCardComponent {
  user = input.required<User>();  // Signal-based โ€” Angular tracks reads automatically
}
Enter fullscreen mode Exit fullscreen mode

When user signal doesn't change between renders, UserCardComponent is skipped entirely by the change detection pass. In a list of 100 user cards where only 3 changed, only those 3 re-render.

Signal Granularity vs. Object Reference

One performance trap with Signals: passing entire objects as signals when only one property changes.

// โŒ Less granular โ€” entire user object is the signal value
// Changing any property triggers re-render of all components reading user()
user = signal<User>(initialUser);

// โœ… More granular โ€” each property is independently reactive
// (when the component owns the data, not just passing it down)
userName   = signal('');
userEmail  = signal('');
userStatus = signal<'active' | 'inactive'>('active');
Enter fullscreen mode Exit fullscreen mode

For presentational components receiving data as inputs, this granularity decision belongs to the smart parent โ€” another reason the smart component should own the state, so it can structure it optimally.

toSignal() and Initial Values

Always provide initialValue to toSignal() to avoid the undefined guard dance:

// โŒ Products might be undefined โ€” template needs @if guard
products = toSignal(this.productSvc.getAll());

// โœ… Always has a value โ€” template is simpler
products = toSignal(this.productSvc.getAll(), { initialValue: [] as Product[] });
Enter fullscreen mode Exit fullscreen mode

Testing Strategy for Both Patterns {#testing-strategy}

Presentational Component Tests: Fast and Isolated

The rule: if you need more than 2 TestBed.configureTestingModule imports to test a component, reconsider whether it's truly presentational.

describe('UserCardComponent', () => {
  beforeEach(() => TestBed.configureTestingModule({
    imports: [UserCardComponent]  // That's it. No service mocks.
  }));

  it('renders user name', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    fixture.componentRef.setInput('user', { name: 'Alice', email: 'a@test.com', isActive: true });
    fixture.detectChanges();
    expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Alice');
  });

  it('appends (Inactive) for inactive users', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    fixture.componentRef.setInput('user', { name: 'Bob', isActive: false });
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('Bob (Inactive)');
  });

  it('emits select output on button click', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    const mockUser = { id: '1', name: 'Alice', isActive: true };
    fixture.componentRef.setInput('user', mockUser);

    const selectSpy = jasmine.createSpy();
    fixture.componentInstance.select.subscribe(selectSpy);

    fixture.detectChanges();
    fixture.nativeElement.querySelector('button').click();

    expect(selectSpy).toHaveBeenCalledWith(mockUser);
  });
});
Enter fullscreen mode Exit fullscreen mode

Smart Component Tests: Business Logic Focus

Smart component tests mock services and assert on state + navigation behavior:

describe('UserManagementComponent', () => {
  let userSvcMock: jasmine.SpyObj<UserService>;
  let routerMock: jasmine.SpyObj<Router>;

  beforeEach(() => {
    userSvcMock = jasmine.createSpyObj('UserService', ['getAll', 'delete']);
    routerMock  = jasmine.createSpyObj('Router', ['navigate']);

    userSvcMock.getAll.and.returnValue(of([mockUser1, mockUser2]));

    TestBed.configureTestingModule({
      imports: [UserManagementComponent],
      providers: [
        { provide: UserService, useValue: userSvcMock },
        { provide: Router, useValue: routerMock }
      ]
    });
  });

  it('filters users by search term', () => {
    const fixture = TestBed.createComponent(UserManagementComponent);
    fixture.detectChanges();

    fixture.componentInstance.onFilterChange('alice');
    fixture.detectChanges();

    expect(fixture.componentInstance.filteredUsers()).toHaveSize(1);
    expect(fixture.componentInstance.filteredUsers()[0].name).toBe('Alice');
  });

  it('navigates to user profile on selection', () => {
    const fixture = TestBed.createComponent(UserManagementComponent);
    fixture.detectChanges();

    fixture.componentInstance.onUserSelect(mockUser1);

    expect(routerMock.navigate).toHaveBeenCalledWith(['/users', mockUser1.id]);
  });
});
Enter fullscreen mode Exit fullscreen mode

Team Scalability and Cognitive Load {#team-scalability}

The ultimate argument for this architecture isn't technical โ€” it's human.

Junior Developers Are Safe

When a junior developer needs to make a change to a UI component, they know the rule: change the template, the computed(), maybe add an input. They don't need to understand the service layer. They can't accidentally break business logic they weren't touching.

The architecture creates a zone of safety for less experienced developers.

Senior Developers Stay Productive

When a senior developer needs to refactor the orchestration layer, they know the presentational components are stable contracts. They can change how state is derived, where it comes from, how events are processed โ€” without touching a single template file.

The architecture reduces the surface area of every change.

Code Reviews Become Faster

When a reviewer sees an import of AuthService in a file under /ui, they know immediately it's wrong. They don't need to understand the full feature context. The violation is visible by convention.

Fast, correct code reviews are a competitive advantage.

Onboarding Accelerates

New team members can be productive in presentational components within their first week. They don't need to understand NgRx, service architecture, or routing until they're ready to work on smart components. The cognitive progression is intentional and gradual.


The Contrarian Take {#contrarian-take}

I'll end with the opinion that starts the best architecture debates:

"The Smart vs Presentational debate was never about components. It was always about architecture boundaries."

Every team that argues against this pattern falls into one of three camps:

Camp 1: "Signals made it irrelevant." Wrong. Signals made it cleaner. The need to separate rendering from orchestration is more important in a Signals-era app, because signal-based reactivity makes it easy to put logic anywhere. The discipline is what prevents signal spaghetti.

Camp 2: "It's over-engineering for small apps." True. For a 3-developer startup shipping an MVP, strict separation is premature. For a 15+ developer team on a 2-year-old app, it's survival infrastructure. Know your context.

Camp 3: "We use NgRx so we already have separation." NgRx doesn't enforce component boundaries. It gives you a place for global state โ€” but nothing stops a component from injecting a selector and also having local service logic and rendering complex UI. You can have NgRx and completely violated component boundaries at the same time.

The pattern isn't about tools. It's about intentional, enforced architectural decisions that make large systems maintainable by human beings who didn't write the original code.

That's what architecture is for.


Summary: The 2026 Mental Model {#summary}

Here's the complete mental model in one place:

Presentational Component Smart Component / Feature Shell
Purpose Render UI from inputs Orchestrate a feature flow
Services Never injected Injected (data, router, analytics)
State Local UI signal() only Feature signal(), computed(), toSignal()
Template Raw HTML + data binding Only component calls
Testing Zero mocks Mocked services
Folder /ui Feature root
Reuse High โ€” across features Low โ€” feature-specific
Contract Inputs + Outputs Business event handlers
Change detection OnPush (always) OnPush (recommended)

The Four Rules

  1. Presentational components render. Smart components coordinate.
  2. Component boundaries are contracts, not folder names.
  3. State ownership should be explicit, not accidental.
  4. Predictable components scale teams.

The One Question to Ask

Before writing any new Angular component, ask:

"Is this component rendering data, or is it deciding what data to render?"

If it's rendering โ†’ presentational, inputs and outputs only.
If it's deciding โ†’ smart, inject what you need, own the state.

That question, asked consistently, is worth more than any tool, library, or framework feature.


Found this useful? Save it before your next frontend refactor.

๐Ÿ’ฌ Discussion: What's the biggest Angular component anti-pattern you still see in production? Drop it in the comments.


Further Reading


๐Ÿ“Œ More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.

๐ŸŒ Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:

๐Ÿ”— LinkedIn โ€” Professional discussions, architecture breakdowns, and engineering insights.
๐Ÿ“ธ Instagram โ€” Visuals, carousels, and designโ€‘driven posts under the Terminal Elite aesthetic.
๐Ÿง  Website โ€” Articles, tutorials, and project showcases.
๐ŸŽฅ YouTube โ€” Deepโ€‘dive videos and live coding sessions.

Top comments (0)