Angular IDPStrong Junior
🔥 0

Модуль 3 · урок 7 з 11

Container і presentation

«Чисте розділення presentation- і container-компонентів» — це формулювання прямо з таблиці ліда. За ним стоїть проста ідея: одні компоненти знають, звідки беруться дані, інші — лише як їх показати. Змішувати ці ролі можна, але дорого.

Дві ролі

Container (розумний)Presentation (дурний)
Звідки даніІнʼєктує сервіси, читає роут, тримає станТільки з input()
Що робить із діямиВикликає сервіси, навігує, зберігаєКидає output() і забуває
Скільки їхПо одному на екранСкільки завгодно
ПеревикористанняНемає, він привʼязаний до екранаЛегко: він нічого не знає про контекст
ТестуванняПотрібні моки сервісівПодав input — перевірив output
ШаблонЗдебільшого композиція інших компонентівРеальна розмітка
СЕРВІС дані, стан, HTTP CONTAINER TasksPage inject, роут, дії inject() TaskFilters input · output TaskTable input · output жоден із них не знає про сервіс Замінити джерело даних → правиш один container. Решта не помічає.
Уся залежність від сервісів зосереджена в одному компоненті на екран. Це і робить решту дерева перевикористовною й дешевою в тестах.

Як це виглядає в коді

// 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>();
}

Ознаки, що поділ порушено

Здоровий presentation-компонент
  • Жодного inject(), крім хіба що суто службових речей.
  • Дані лише через input(), дії лише через output().
  • Його можна вставити на іншу сторінку й він працюватиме.
  • У тесті не потрібен жоден мок.
Червоні прапорці
  • У «дурному» компоненті зʼявився inject(HttpClient).
  • Він сам навігує через Router.
  • Він читає ActivatedRoute, щоб дізнатись id.
  • Він мутує масив, який отримав в input.

Чому це повʼязано з OnPush

Поділ і стратегія перевірки підтримують одне одного. Presentation-компонент отримує все через input, тож для нього OnPush працює ідеально: змінилось посилання — перевірився, ні — пропустили. А container рідко буває важким: у нього майже немає власної розмітки, лише композиція.

Скільки контейнерів потрібно

Орієнтир — один на маршрут. Якщо на екрані зʼявився другий компонент, який сам інʼєктує сервіси, варто спитати себе, чи не варто підняти цю логіку вище. Виняток — справді самостійні блоки на кшталт віджета сповіщень, який живе сам по собі.