Модуль 3 · урок 7 з 11
Container і presentation
«Чисте розділення presentation- і container-компонентів» — це формулювання прямо з таблиці ліда. За ним стоїть проста ідея: одні компоненти знають, звідки беруться дані, інші — лише як їх показати. Змішувати ці ролі можна, але дорого.
Дві ролі
| Container (розумний) | Presentation (дурний) | |
|---|---|---|
| Звідки дані | Інʼєктує сервіси, читає роут, тримає стан | Тільки з input() |
| Що робить із діями | Викликає сервіси, навігує, зберігає | Кидає output() і забуває |
| Скільки їх | По одному на екран | Скільки завгодно |
| Перевикористання | Немає, він привʼязаний до екрана | Легко: він нічого не знає про контекст |
| Тестування | Потрібні моки сервісів | Подав input — перевірив output |
| Шаблон | Здебільшого композиція інших компонентів | Реальна розмітка |
Як це виглядає в коді
// CONTAINER: знає все про дані
@Component({
selector: 'app-tasks-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TaskFilters, TaskTable],
template: `
<app-task-filters
[value]="filters()"
(changed)="filters.set($event)" />
<app-task-table
[rows]="visible()"
[loading]="loading()"
(rowPicked)="open($event)" />
`,
})
export class TasksPage {
private store = inject(TaskStore);
private router = inject(Router);
readonly filters = signal<Filters>({ status: 'all', query: '' });
readonly loading = this.store.loading;
readonly visible = computed(() => this.store.filtered(this.filters()));
open(task: Task): void {
this.router.navigate(['/tasks', task.id]);
}
}
// PRESENTATION: не знає нічого
@Component({
selector: 'app-task-table',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (row of rows(); track row.id) {
<tr (click)="rowPicked.emit(row)">…</tr>
} @empty {
<app-empty-state />
}
`,
})
export class TaskTable {
rows = input.required<readonly Task[]>(); // readonly — обіцянка не мутувати
loading = input(false);
rowPicked = output<Task>();
}
Ознаки, що поділ порушено
- Жодного
inject(), крім хіба що суто службових речей. - Дані лише через
input(), дії лише черезoutput(). - Його можна вставити на іншу сторінку й він працюватиме.
- У тесті не потрібен жоден мок.
- У «дурному» компоненті зʼявився
inject(HttpClient). - Він сам навігує через
Router. - Він читає
ActivatedRoute, щоб дізнатись id. - Він мутує масив, який отримав в
input.
Чому це повʼязано з OnPush
Поділ і стратегія перевірки підтримують одне одного. Presentation-компонент отримує все через
input, тож для нього OnPush працює ідеально: змінилось посилання —
перевірився, ні — пропустили. А container рідко буває важким: у нього майже немає власної
розмітки, лише композиція.
Орієнтир — один на маршрут. Якщо на екрані зʼявився другий компонент, який сам інʼєктує сервіси, варто спитати себе, чи не варто підняти цю логіку вище. Виняток — справді самостійні блоки на кшталт віджета сповіщень, який живе сам по собі.