Module Overview
Estimated Time: 4-5 hours | Difficulty: Advanced | Prerequisites: Module 11
- Smart vs Presentational components
- Container pattern
- Facade pattern for state management
- Feature modules and domain-driven design
- Dynamic components
- Content projection patterns
Smart vs Presentational Components
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Component Classification │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ SMART (Container) Components PRESENTATIONAL (Dumb) Components │
│ ───────────────────────────── ─────────────────────────────── │
│ • Know about services • Pure, no side effects │
│ • Manage state • Only know about inputs/outputs │
│ • Handle business logic • Highly reusable │
│ • Coordinate child components • Easy to test │
│ • Usually pages/features • Usually UI elements │
│ │
│ Example: Example: │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ UsersPageComponent │ │ UserCardComponent │ │
│ │ ───────────────── │ │ ──────────────── │ │
│ │ - injects UserService │ │ - @Input() user │ │
│ │ - loads users │ │ - @Output() select │ │
│ │ - handles CRUD │ │ - pure template │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Presentational Component
Copy
// user-card.component.ts
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card" (click)="cardClicked()">
<img [src]="user().avatar" [alt]="user().name" />
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<span class="role">{{ user().role }}</span>
</div>
`
})
export class UserCardComponent {
user = input.required<User>();
select = output<User>();
cardClicked() {
this.select.emit(this.user());
}
}
Smart Component
Copy
// users-page.component.ts
@Component({
selector: 'app-users-page',
standalone: true,
imports: [UserCardComponent, UserFormComponent],
template: `
<div class="users-page">
<header>
<h1>Users</h1>
<button (click)="showForm.set(true)">Add User</button>
</header>
@if (loading()) {
<app-spinner />
} @else {
<div class="grid">
@for (user of users(); track user.id) {
<app-user-card
[user]="user"
(select)="selectUser($event)"
/>
}
</div>
}
@if (showForm()) {
<app-user-form
[user]="selectedUser()"
(save)="saveUser($event)"
(cancel)="closeForm()"
/>
}
</div>
`
})
export class UsersPageComponent implements OnInit {
private userService = inject(UserService);
users = signal<User[]>([]);
loading = signal(false);
selectedUser = signal<User | null>(null);
showForm = signal(false);
ngOnInit() {
this.loadUsers();
}
loadUsers() {
this.loading.set(true);
this.userService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
selectUser(user: User) {
this.selectedUser.set(user);
this.showForm.set(true);
}
saveUser(user: User) {
const save$ = user.id
? this.userService.updateUser(user.id, user)
: this.userService.createUser(user);
save$.subscribe(() => {
this.loadUsers();
this.closeForm();
});
}
closeForm() {
this.selectedUser.set(null);
this.showForm.set(false);
}
}
Facade Pattern
Simplify complex state management with facades:Copy
// users.facade.ts
@Injectable({ providedIn: 'root' })
export class UsersFacade {
private userService = inject(UserService);
private notificationService = inject(NotificationService);
private router = inject(Router);
// State
private _users = signal<User[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
private _selectedUser = signal<User | null>(null);
// Public selectors (read-only)
readonly users = this._users.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly selectedUser = this._selectedUser.asReadonly();
// Computed values
readonly userCount = computed(() => this._users().length);
readonly activeUsers = computed(() =>
this._users().filter(u => u.active)
);
// Actions
loadUsers() {
this._loading.set(true);
this._error.set(null);
this.userService.getUsers().pipe(
catchError(error => {
this._error.set('Failed to load users');
return of([]);
}),
finalize(() => this._loading.set(false))
).subscribe(users => this._users.set(users));
}
selectUser(id: number) {
const user = this._users().find(u => u.id === id);
this._selectedUser.set(user ?? null);
}
async createUser(userData: CreateUserDto) {
this._loading.set(true);
try {
const user = await firstValueFrom(
this.userService.createUser(userData)
);
this._users.update(users => [...users, user]);
this.notificationService.success('User created');
this.router.navigate(['/users', user.id]);
return user;
} catch (error) {
this.notificationService.error('Failed to create user');
throw error;
} finally {
this._loading.set(false);
}
}
async updateUser(id: number, updates: Partial<User>) {
this._loading.set(true);
try {
const updated = await firstValueFrom(
this.userService.updateUser(id, updates)
);
this._users.update(users =>
users.map(u => u.id === id ? updated : u)
);
if (this._selectedUser()?.id === id) {
this._selectedUser.set(updated);
}
this.notificationService.success('User updated');
return updated;
} catch (error) {
this.notificationService.error('Failed to update user');
throw error;
} finally {
this._loading.set(false);
}
}
async deleteUser(id: number) {
if (!confirm('Delete this user?')) return;
try {
await firstValueFrom(this.userService.deleteUser(id));
this._users.update(users => users.filter(u => u.id !== id));
if (this._selectedUser()?.id === id) {
this._selectedUser.set(null);
}
this.notificationService.success('User deleted');
} catch (error) {
this.notificationService.error('Failed to delete user');
}
}
}
Copy
// users-page.component.ts - Much simpler with facade
@Component({
selector: 'app-users-page',
standalone: true,
template: `
@if (facade.loading()) {
<app-spinner />
} @else if (facade.error()) {
<app-error [message]="facade.error()" (retry)="facade.loadUsers()" />
} @else {
<app-users-list
[users]="facade.users()"
(select)="facade.selectUser($event)"
(delete)="facade.deleteUser($event)"
/>
}
`
})
export class UsersPageComponent implements OnInit {
facade = inject(UsersFacade);
ngOnInit() {
this.facade.loadUsers();
}
}
Feature Module Structure
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Feature Module Structure │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ src/ │
│ └── app/ │
│ ├── core/ # Singleton services │
│ │ ├── services/ │
│ │ ├── guards/ │
│ │ ├── interceptors/ │
│ │ └── core.module.ts │
│ │ │
│ ├── shared/ # Reusable components │
│ │ ├── components/ │
│ │ ├── directives/ │
│ │ ├── pipes/ │
│ │ └── shared.module.ts │
│ │ │
│ └── features/ # Feature modules │
│ ├── users/ │
│ │ ├── components/ # Presentational │
│ │ ├── containers/ # Smart components │
│ │ ├── services/ # Feature-specific │
│ │ ├── models/ │
│ │ ├── state/ # Feature state (facade/store) │
│ │ ├── users.routes.ts │
│ │ └── index.ts # Public API │
│ │ │
│ └── products/ │
│ └── ... │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Feature Routes
Copy
// features/users/users.routes.ts
export const USERS_ROUTES: Routes = [
{
path: '',
component: UsersLayoutComponent,
children: [
{ path: '', component: UsersListComponent },
{ path: 'new', component: UserFormComponent },
{
path: ':id',
component: UserDetailComponent,
resolve: { user: userResolver }
},
{
path: ':id/edit',
component: UserFormComponent,
resolve: { user: userResolver }
}
]
}
];
// app.routes.ts
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{
path: 'users',
loadChildren: () => import('./features/users/users.routes')
.then(m => m.USERS_ROUTES)
},
{
path: 'products',
loadChildren: () => import('./features/products/products.routes')
.then(m => m.PRODUCTS_ROUTES)
}
];
Content Projection
Basic Projection
Copy
// card.component.ts
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header class="card-header">
<ng-content select="[card-header]" />
</header>
<div class="card-body">
<ng-content />
</div>
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
</div>
`
})
export class CardComponent {}
// Usage
@Component({
template: `
<app-card>
<h2 card-header>Card Title</h2>
<p>This is the main content</p>
<p>It goes in the body</p>
<button card-footer>Action</button>
</app-card>
`
})
Conditional Projection with ngTemplateOutlet
Copy
// data-list.component.ts
@Component({
selector: 'app-data-list',
standalone: true,
imports: [NgTemplateOutlet],
template: `
@if (loading) {
<ng-container *ngTemplateOutlet="loadingTemplate || defaultLoading" />
} @else if (items.length === 0) {
<ng-container *ngTemplateOutlet="emptyTemplate || defaultEmpty" />
} @else {
@for (item of items; track item) {
<ng-container
*ngTemplateOutlet="itemTemplate; context: { $implicit: item }"
/>
}
}
<ng-template #defaultLoading>
<div class="loading">Loading...</div>
</ng-template>
<ng-template #defaultEmpty>
<div class="empty">No items found</div>
</ng-template>
`
})
export class DataListComponent<T> {
@Input() items: T[] = [];
@Input() loading = false;
@ContentChild('item') itemTemplate!: TemplateRef<{ $implicit: T }>;
@ContentChild('loading') loadingTemplate?: TemplateRef<void>;
@ContentChild('empty') emptyTemplate?: TemplateRef<void>;
}
// Usage
@Component({
template: `
<app-data-list [items]="users" [loading]="isLoading">
<ng-template #item let-user>
<div class="user-row">
<span>{{ user.name }}</span>
<span>{{ user.email }}</span>
</div>
</ng-template>
<ng-template #loading>
<app-skeleton-rows [count]="5" />
</ng-template>
<ng-template #empty>
<div class="no-users">
<img src="empty.svg" />
<p>No users yet. Create one!</p>
</div>
</ng-template>
</app-data-list>
`
})
Dynamic Components
Copy
// dynamic-modal.service.ts
@Injectable({ providedIn: 'root' })
export class ModalService {
private viewContainerRef!: ViewContainerRef;
setContainer(vcr: ViewContainerRef) {
this.viewContainerRef = vcr;
}
open<C, R = any>(
component: Type<C>,
config?: { inputs?: Partial<C>; }
): Observable<R> {
const componentRef = this.viewContainerRef.createComponent(component);
// Set inputs
if (config?.inputs) {
Object.assign(componentRef.instance, config.inputs);
}
// Handle close
const result$ = new Subject<R>();
if ('close' in componentRef.instance) {
(componentRef.instance as any).close.subscribe((result: R) => {
result$.next(result);
result$.complete();
componentRef.destroy();
});
}
return result$.asObservable();
}
}
// confirm-dialog.component.ts
@Component({
selector: 'app-confirm-dialog',
standalone: true,
template: `
<div class="modal-backdrop" (click)="close.emit(false)">
<div class="modal" (click)="$event.stopPropagation()">
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<div class="actions">
<button (click)="close.emit(false)">Cancel</button>
<button class="primary" (click)="close.emit(true)">Confirm</button>
</div>
</div>
</div>
`
})
export class ConfirmDialogComponent {
@Input() title = 'Confirm';
@Input() message = 'Are you sure?';
@Output() close = new EventEmitter<boolean>();
}
// Usage
@Component({...})
export class SomeComponent {
private modalService = inject(ModalService);
async deleteItem(item: Item) {
const confirmed = await firstValueFrom(
this.modalService.open(ConfirmDialogComponent, {
inputs: {
title: 'Delete Item',
message: `Delete "${item.name}"?`
}
})
);
if (confirmed) {
this.itemService.delete(item.id);
}
}
}
Control Value Accessor
Create custom form controls:Copy
// rating.component.ts
@Component({
selector: 'app-rating',
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingComponent),
multi: true
}
],
template: `
<div class="rating" [class.disabled]="disabled">
@for (star of stars; track star) {
<button
type="button"
(click)="rate(star)"
(mouseenter)="hover.set(star)"
(mouseleave)="hover.set(0)"
[class.active]="star <= (hover() || value())"
[disabled]="disabled"
>
★
</button>
}
</div>
`
})
export class RatingComponent implements ControlValueAccessor {
stars = [1, 2, 3, 4, 5];
value = signal(0);
hover = signal(0);
disabled = false;
private onChange = (value: number) => {};
private onTouched = () => {};
// Called when form sets value
writeValue(value: number): void {
this.value.set(value || 0);
}
// Register callback for value changes
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
// Register callback for touch
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
// Called when form enables/disables
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
rate(star: number): void {
if (this.disabled) return;
this.value.set(star);
this.onChange(star);
this.onTouched();
}
}
// Usage with reactive forms
@Component({
template: `
<form [formGroup]="form">
<app-rating formControlName="rating" />
</form>
`
})
export class ReviewFormComponent {
form = new FormGroup({
rating: new FormControl(0, Validators.required),
comment: new FormControl('')
});
}
Inheritance vs Composition
Copy
// ❌ Inheritance - tightly coupled, inflexible
class BaseListComponent<T> {
items: T[] = [];
loading = false;
load() { /* ... */ }
delete(id: number) { /* ... */ }
}
class UsersListComponent extends BaseListComponent<User> {
// Hard to customize, tight coupling
}
// ✅ Composition - flexible, testable
@Injectable()
export class ListState<T extends { id: number }> {
items = signal<T[]>([]);
loading = signal(false);
setItems(items: T[]) { this.items.set(items); }
addItem(item: T) { this.items.update(i => [...i, item]); }
removeItem(id: number) { this.items.update(i => i.filter(x => x.id !== id)); }
updateItem(id: number, updates: Partial<T>) {
this.items.update(items =>
items.map(i => i.id === id ? { ...i, ...updates } : i)
);
}
}
@Component({
providers: [ListState], // Each component gets its own instance
template: `...`
})
export class UsersListComponent {
state = inject(ListState<User>);
private userService = inject(UserService);
ngOnInit() {
this.state.loading.set(true);
this.userService.getUsers().subscribe(users => {
this.state.setItems(users);
this.state.loading.set(false);
});
}
}
Practice Exercise
Exercise: Build a Generic Data Table Component
Create a reusable data table with:
- Generic type support
- Column configuration via input
- Sorting and pagination
- Row selection
- Custom cell templates via content projection
Solution
Solution
Copy
// data-table.types.ts
export interface ColumnDef<T> {
key: keyof T | string;
header: string;
sortable?: boolean;
width?: string;
}
// data-table.component.ts
@Component({
selector: 'app-data-table',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<table>
<thead>
<tr>
@if (selectable) {
<th class="checkbox-col">
<input
type="checkbox"
[checked]="allSelected()"
(change)="toggleAll()"
/>
</th>
}
@for (col of columns(); track col.key) {
<th
[style.width]="col.width"
[class.sortable]="col.sortable"
(click)="col.sortable && toggleSort(col.key)"
>
{{ col.header }}
@if (sortKey() === col.key) {
<span>{{ sortDir() === 'asc' ? '▲' : '▼' }}</span>
}
</th>
}
</tr>
</thead>
<tbody>
@for (row of paginatedData(); track trackByFn(row)) {
<tr [class.selected]="isSelected(row)">
@if (selectable) {
<td>
<input
type="checkbox"
[checked]="isSelected(row)"
(change)="toggleSelection(row)"
/>
</td>
}
@for (col of columns(); track col.key) {
<td>
@if (getCellTemplate(col.key); as template) {
<ng-container
*ngTemplateOutlet="template; context: { $implicit: row, column: col }"
/>
} @else {
{{ getNestedValue(row, col.key) }}
}
</td>
}
</tr>
}
</tbody>
</table>
@if (paginate) {
<div class="pagination">
<button
[disabled]="page() === 1"
(click)="page.set(page() - 1)"
>
Previous
</button>
<span>Page {{ page() }} of {{ totalPages() }}</span>
<button
[disabled]="page() >= totalPages()"
(click)="page.set(page() + 1)"
>
Next
</button>
</div>
}
`
})
export class DataTableComponent<T extends { id: number | string }> {
data = input.required<T[]>();
columns = input.required<ColumnDef<T>[]>();
selectable = input(false);
paginate = input(false);
pageSize = input(10);
trackByFn = input<(item: T) => any>((item) => item.id);
selectionChange = output<T[]>();
sortKey = signal<string | keyof T | null>(null);
sortDir = signal<'asc' | 'desc'>('asc');
page = signal(1);
selected = signal<Set<any>>(new Set());
@ContentChildren('cell') cellTemplates!: QueryList<TemplateRef<any>>;
private cellTemplateMap = new Map<string, TemplateRef<any>>();
sortedData = computed(() => {
const data = [...this.data()];
const key = this.sortKey();
if (!key) return data;
return data.sort((a, b) => {
const aVal = this.getNestedValue(a, key);
const bVal = this.getNestedValue(b, key);
const dir = this.sortDir() === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * dir;
if (aVal > bVal) return 1 * dir;
return 0;
});
});
paginatedData = computed(() => {
if (!this.paginate()) return this.sortedData();
const start = (this.page() - 1) * this.pageSize();
return this.sortedData().slice(start, start + this.pageSize());
});
totalPages = computed(() =>
Math.ceil(this.data().length / this.pageSize())
);
allSelected = computed(() => {
const current = this.paginatedData();
return current.length > 0 &&
current.every(row => this.selected().has(this.trackByFn()(row)));
});
toggleSort(key: string | keyof T) {
if (this.sortKey() === key) {
this.sortDir.update(d => d === 'asc' ? 'desc' : 'asc');
} else {
this.sortKey.set(key);
this.sortDir.set('asc');
}
}
isSelected(row: T): boolean {
return this.selected().has(this.trackByFn()(row));
}
toggleSelection(row: T) {
const id = this.trackByFn()(row);
this.selected.update(set => {
const newSet = new Set(set);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
this.emitSelection();
}
toggleAll() {
if (this.allSelected()) {
this.selected.set(new Set());
} else {
const ids = this.paginatedData().map(row => this.trackByFn()(row));
this.selected.set(new Set(ids));
}
this.emitSelection();
}
getCellTemplate(key: string | keyof T): TemplateRef<any> | null {
return this.cellTemplateMap.get(String(key)) ?? null;
}
getNestedValue(obj: any, path: string | keyof T): any {
return String(path).split('.').reduce((o, k) => o?.[k], obj);
}
private emitSelection() {
const selectedItems = this.data().filter(row =>
this.selected().has(this.trackByFn()(row))
);
this.selectionChange.emit(selectedItems);
}
}
// Usage
@Component({
template: `
<app-data-table
[data]="users()"
[columns]="columns"
[selectable]="true"
[paginate]="true"
[pageSize]="20"
(selectionChange)="onSelectionChange($event)"
>
<ng-template #cell="avatar" let-user>
<img [src]="user.avatar" class="avatar" />
</ng-template>
<ng-template #cell="actions" let-user>
<button (click)="edit(user)">Edit</button>
<button (click)="delete(user)">Delete</button>
</ng-template>
</app-data-table>
`
})
export class UsersTableComponent {
columns: ColumnDef<User>[] = [
{ key: 'avatar', header: '', width: '60px' },
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'role', header: 'Role', sortable: true },
{ key: 'actions', header: 'Actions', width: '150px' }
];
}
Summary
1
Smart/Presentational
Separate stateful containers from pure presentational components
2
Facade Pattern
Simplify complex state with a unified API
3
Feature Modules
Organize by domain with clear boundaries
4
Content Projection
Build flexible, customizable components
5
Composition over Inheritance
Prefer composable patterns for flexibility
Next Steps
Next: Server-Side Rendering
Learn SSR and hydration for better performance and SEO