"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
- Why This Pattern Still Matters in 2026
- The Old World: What We Got Wrong
- What Signals Actually Changed
- Presentational Components: The New Definition
- Smart Components: The Orchestration Layer
- The Real Question: Who Owns the State?
- Component Boundaries as Contracts
- The Signal() Decision Framework
- Code: The 2026 Pattern in Practice
- Anti-Patterns Still Destroying Enterprise Apps
- Enterprise Scalability Reasoning
- Feature Architecture and the Module Boundary Question
- Performance Implications
- Testing Strategy for Both Patterns
- Team Scalability and Cognitive Load
- The Contrarian Take
- 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
NgModulefor every component - Angular 16 shipped Signals as developer preview
-
Angular 17 stabilized
signal(),computed(),effect(), signal-basedinput()andoutput() -
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"
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:
-
BehaviorSubjectโ verbose, manual subscription management, easy to leak -
asyncpipe โ clean, but only works in templates, no imperative access - 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); }
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())) ?? []
);
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
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
);
}
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.
}
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()andcomputed() - 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.');
}
}
}
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); }
}
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())
)
);
}
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([]); }
}
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)
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
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 (
/uifolder) โ 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
@Injectablestore โ 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
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.
}
// 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})`
);
}
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);
}
}
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');
}
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
}
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]);
}
}
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
}
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
);
}
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
}
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); // ๐จ
}
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
}
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');
});
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]);
});
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
...
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)
);
}
}
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
}
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');
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[] });
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);
});
});
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]);
});
});
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
- Presentational components render. Smart components coordinate.
- Component boundaries are contracts, not folder names.
- State ownership should be explicit, not accidental.
- 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
- Angular Signals documentation
- Angular standalone components guide
- NgRx Signals Store
- Sheriff โ Angular module boundary enforcement
- angular-eslint โ ESLint rules for Angular
๐ 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)