Курс: Создание веб-приложений с использованием Angular и React

Урок №4

Angular: данные и события между компонентами

Теория — 90 минут
Практика — 90 минут

Сквозной проект курса: TaskFlow

Данные вниз, события вверхРодитель (TaskList)владеет данными tasks[task]="..." — вниз(toggled)="..." — вверх(created), (deleted)voidданные внизсобытия вверхДочерний (TaskItem)task = input.required()toggled = output()не меняет данные самсообщает о намеренииРезультатсостояние меняется в одном местешаблон перерисовываетсякомпоненты остаютсяпереиспользуемыми
Рисунок 1. Центральный принцип связи: данные передаются вниз через входы, события поднимаются вверх через выходы. Состояние живёт у владельца данных.
Главная идея урока. Angular-компоненты общаются так: родитель передаёт данные вниз через входы (input), а дочерние компоненты сообщают о событиях наверх через выходы (output). Состояние хранится у того, кому данные принадлежат, — обычно у ближайшего общего родителя.

Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.

Содержание

  1. 1. Зачем компонентам общаться
  2. 2. Обзор темы: что мы уже знаем и что добавим
  3. 3. Входные данные: input() и @Input
  4. 4. Обязательные, необязательные входы и значения по умолчанию
  5. 5. Выходы: output() и @Output
  6. 6. Принцип «данные вниз, события вверх»
  7. 7. Кто владеет состоянием: lifting state
  8. 8. Полный цикл: TaskItem и переключение
  9. 9. Полный цикл: TaskForm и создание
  10. 10. Полный цикл: удаление задачи
  11. 11. Деструктуризация данных в методах
  12. 12. Идентификация: id задачи и track
  13. 13. События с данными: payload выходов
  14. 14. Выбор событий: что выносить наверх
  15. 15. Неизменяемость данных при обновлении
  16. 16. Двусторонняя связь bananainabox и model()
  17. 17. Сравнение: входы/выходы Angular и props/React
  18. 18. Композиция TaskFlow: полное дерево
  19. 19. Практика 1: связываем TaskItem и TaskList
  20. 20. Практика 2: фильтрация через выбор в FilterBar
  21. 21. Практика 3: статистика из родителя
  22. 22. Практика 4: добавление задачи из формы
  23. 23. Практика 5: удаление задачи
  24. 24. Практика 6: редактирование (замена объекта)
  25. 24.1. $event и типизация payload событий
  26. 24.2. Почему сигналы: реактивность и перерисовка
  27. 24.3. Transform inputs: нормализация входных данных
  28. 24.4. model() углублённо: двусторонний вход-выход
  29. 24.5. Связь «братьев» через общего родителя
  30. 24.6. Дизайн событий: именование, payload, деструктуризация
  31. 24.7. Аргументы @for: $index, $count, контекст в событиях
  32. 24.8. Доступность в событиях: a11y и клавиатура
  33. 24.9. Отладка потока данных между компонентами
  34. 24.10. Собираем всё: соединение TaskFlow
  35. 24.11. Когда не поднимать состояние вверх
  36. 24.12. Чтение legacy-кода: @Input/@Output и EventEmitter
  37. 24.13. Практика 7: соединить форму, фильтр и список в один экран
  38. 24.14. Переиспользование компонента: один компонент, разные места
  39. 25. Самостоятельная работа
  40. 26. Подсказки к самостоятельной работе
  41. 27. Частые ошибки связи компонентов
  42. 28. Резюме: как соединить компоненты
  43. 29. Контрольные вопросы
  44. 30. Мини-словарь
  45. 31. Источники и продолжение
  46. 32. Чек-лист урока
Урок 4 • Раздел 1

1. Зачем компонентам общаться

Angular-приложение — это дерево компонентов. Чтобы оно работало как единое целое, компоненты должны передавать друг другу данные и сообщать о действиях пользователя.

Аналогия — дерево компонентов. Представьте матрёшку или семейное дерево. Внутри большой матрёшки сидит поменьше, внутри неё — ещё меньше. Так же и компоненты: один большой (родитель) внутри себя содержит поменьше (детей), а те — своих детей. Или как в семье: бабушка → мама → ребёнок. «Родитель» — тот, кто внутри себя разместил другого; «ребёнок» — тот, кого разместили. Чтобы передать записку, идут сверху вниз; чтобы позвать на помощь — снизу вверх.

В уроке 3 мы построили TaskItem, TaskList, FilterBar, StatsBar и форму-заглушку. Каждый компонент был самостоятельным и содержал собственные данные. Но настоящему приложению нужна связь:

Без связи компоненты — изолированные острова, которые не могут собраться в целое. Связь делает дерево компонентов рабочим приложением.

Зачем это важно для React-Части курса? Та же проблема «как компоненты общаются» решается в React через props (вниз) и callback-функции (вверх). Идея идентична, отличается синтаксис. Разобравшись в Angular сейчас, вы легко перенесёте принцип в React.
Урок 4 • Раздел 2

2. Обзор темы: что мы уже знаем и что добавим

В уроке 3 мы уже познакомились с input() и output(). В этом уроке систематизируем знание и доведём его до полного рабочего цикла.

КонструкцияУрок 3Урок 4
input()Первый взглядОбязательные/необязательные, дефолты, алиасы
output()Событие со строкойPayload разных типов, выбор, дизайн событий
«Данные вниз, события вверх»ИдеяПолный цикл: toggle, create, delete, edit
Владелец состоянияLifting state up, где хранить данные
trackОбязательный атрибутЗачем и как выбрать ключ
НеизменяемостьупоминаниеПаттерны обновления массивов и объектов
Связность курса. Урок 3 — это «как компонент устроен внутри». Урок 4 — «как компоненты соединяются между собой». Оба опираются на модель Task из урока 2 и на методы массивов map/filter/find.
Урок 4 • Раздел 3

3. Входные данные: input() и @Input

Вход (input) — это то, что родитель передаёт дочернему компоненту. Современный способ — функция input(), возвращающая сигнал.

Аналогия — вход (input). Вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input(). А «сигнал» здесь — просто способ хранить значение так, чтобы Angular заметил, когда оно поменялось (данные идут вниз, и при изменении все подписчики сразу узнают об этом).

Где применяем (3. Входные данные: input() и @Input): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// task-item.ts
import { Component, input } from '@angular/core';
import type { Task } from '../task';

@Component({
  selector: 'app-task-item',
  standalone: true,
  templateUrl: './task-item.html',
})
export class TaskItem {
  // вход: родитель передаёт задачу через [task]="task"
  readonly task = input.required<Task>();
}

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Родитель передаёт значение через property binding:

Где применяем (3. Входные данные: input() и @Input): Так Angular перебирает задачи и рисует их на экране.

Аналогия из жизни: @for + track — как рассадка учеников по именным бейджам. Angular знает, какой элемент какому соответствует по стабильному ключу (track task.id), и обновляет только нужного «ученика», а не весь класс.

@for (task of tasks(); track task.id) {
  <app-task-item [task]="task" />
}

Разбор по шагам: (1) @for перебирает элементы сигнала; (2) track говорит, по какому ключу узнавать задачу; (3) для каждой рисуется дочерний компонент.

На экране: каждая задача появляется как отдельная строка списка.

В шаблоне дочернего вход читается вызовом: task().title. В классе — this.task().

Старый синтаксис @Input. В существующих проектах вы встретите декоратор @Input():

Где применяем (3. Входные данные: input() и @Input): Так создаётся и описывается отдельная деталь приложения.

Аналогия из жизни: компонент — как деталь конструктора или запчасть машины. Один файл = одна деталь со своим назначением; из деталей собирают большую машину-приложение.

import { Component, Input } from '@angular/core';

@Component({ selector: 'app-task-item', ... })
export class TaskItem {
  @Input() task!: Task;
  // в шаблоне: task.title (без скобок)
}

Разбор по шагам: (1) декоратор @Component задаёт имя тега и файлы; (2) внутри класса — данные и методы; (3) standalone:true позволяет импортировать компонент напрямую.

На экране: готовая деталь встаёт на своё место в приложении.

Оба подхода работают. Наш курс использует современный input(); знание @Input полезно для чтения legacy-кода.

Урок 4 • Раздел 4

4. Обязательные, необязательные входы и значения по умолчанию

input() позволяет управлять требованиями ко входу.

Обязательный вход

Где применяем (Обязательный вход): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly task = input.required<Task>();
// Angular требует: вызвавший обязан передать [task]

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Необязательный вход

Где применяем (Необязательный вход): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly note = input<string>();
// может не передаваться; note() вернёт undefined

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Со значением по умолчанию

Где применяем (Со значением по умолчанию): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly maxVisible = input(3);
// если не передано — 3
readonly placeholder = input('Введите задачу');
// по умолчанию строка

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Псевдоним (alias)

Где применяем (Псевдоним (alias)): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly filter = input<TaskFilter>('all', { alias: 'value' });
// родитель пишет [value], а в классе читается как filter()

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Псевдоним полезен для совместимости или коротких публичных имён, но чаще имя входа и есть публичное имя — алиасы используйте умеренно.

Когда required, когда нет? Если компонент бессмыслен без данных (TaskItem без задачи) — применяйте required. Если поле необязательно (заголовок, метка, опция) — обычный input() с дефолтом. Обязательный вход раньше обнаружит ошибку подключения.

Где применяем (Псевдоним (alias)): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// полный набор в TaskListHeader
readonly title = input('Мои задачи');
readonly count = input(0);
readonly showStats = input(true);

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

▶️ Быстрый запуск. Аналог input() на чистом JS/HTML: родитель передаёт данные вниз, записывая атрибут/свойство дочернему узлу. Angular-версия запускается через ng serve.
Песочница: нажми «Запустить» — и увидишь, как родитель передаёт задачу ребёнку вниз через атрибут (как письмо в почтовый ящик). Меняй код и запускай снова.
Песочница: input() — данные вниз (аналог на JS)
Урок 4 • Раздел 5

5. Выходы: output() и @Output

Выход (output) — это событие, которое дочерний компонент испускает, чтобы уведомить родителя.

Аналогия — выход (output). Выход — это когда ребёнок зовёт родителя: «Мам, посмотри!» или «Я нажал кнопку!». Ребёнок не решает за маму, что делать дальше, — он только сообщает о факте. В Angular ребёнок вызывает this.toggled.emit() (позвал), а родитель, услышав (toggled), сам решает, что с этим делать. Вход — почтовый ящик ребёнка (данные вниз), выход — крик вверх.

Где применяем (5. Выходы: output() и @Output): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

import { Component, output } from '@angular/core';

@Component({
  selector: 'app-task-item',
  standalone: true,
  templateUrl: './task-item.html',
})
export class TaskItem {
  // выход: дочерний сообщает родителю о действии
  readonly toggled = output<void>();

  toggle() {
    // испускаем событие — оно всплывёт к родителю через (toggled)
    this.toggled.emit();
  }
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (5. Выходы: output() и @Output): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<input type="checkbox" (change)="toggle()" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Родитель слушает событие через event binding:

Где применяем (5. Выходы: output() и @Output): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<app-task-item [task]="task" (toggled)="onToggle($event)" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Метод emit() испускает событие. Выход можно объявить без типа — тогда он пустой, как здесь.

Старый синтаксис @Output. В legacy-коде — декоратор @Output() с EventEmitter:

Где применяем (5. Выходы: output() и @Output): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

import { Component, Output, EventEmitter } from '@angular/core';

@Component({ selector: 'app-task-item', ... })
export class TaskItem {
  @Output() toggled = new EventEmitter<void>();
  // this.toggled.emit();
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Наш курс использует output(). Принцип одинаков: «испустить событие».

▶️ Быстрый запуск. Аналог output() на чистом JS/HTML: дочерний узел испускает пользовательское событие, которое всплывает к родителю. Angular-версия запускается через ng serve.
Песочница: нажми «Запустить» — и увидишь, как дочерний зовёт родителя кликом (событие всплывает вверх). Меняй код и запускай снова.
Песочница: output() — событие вверх (аналог на JS)
Урок 4 • Раздел 6

6. Принцип «данные вниз, события вверх»

Связь между компонентами строго направлена. Это архитектурное правило, а не деталь синтаксиса.

💡 Однонаправленный поток. Дерево компонентов TaskFlow работает по принципу однонаправленного потока: данные идут вниз через input(), а события — вверх через output(). Никогда наоборот: дочерний не меняет чужие данные сам.
Почему состояние у родителяTaskList (родитель)tasks — единственный источникonToggle, onCreated, onDeleteсостояние + логикаданныесобытияTaskItemtask = input.required()toggled = output()показывает, не меняеточистный и тестируемыйЗачем строгое направление• легче понять поток данных• один источник правды• компоненты переиспользуемы• проще тестировать• меньше взаимных связей
Рисунок 2. Состояние там, где ему принадлежит: у родителя. Дочерние компоненты «слепые» и зависят только от входов и выходов.

Это правило делает поток данных предсказуемым: в любой момент вы знаете, где живёт конкретное значение и кто его меняет.

Урок 4 • Раздел 7

7. Кто владеет состоянием: lifting state

Вопрос «где хранить состояние?» определяет архитектуру. Простое правило: состояние хранится там, где данные нужны нескольким компонентам, — у их общего (ближайшего) родителя. Когда данные поднимают от дочернего к родителю, это называют поднятием состояния (lifting state up).

Пример: счётчик выполненных задач нужен и StatsBar, и FilterBar, и TaskItem (чтобы зачеркнуть). Значит, список tasks живёт в TaskList, а дочерние получают то, что им нужно.

Аналогия — поднятие состояния (lifting state). Представьте общий семейный календарь на холодильнике. Если про день рождения должны знать и мама, и папа, и ребёнок — его вешают в одно общее место, а не пишут каждому на лбу. «Поднять состояние» = перенести общие данные туда, где их видят все, кому нужно, — к их ближайшему общему родителю. Если данные нужны только одному человеку, пусть лежат у него в блокноте (локально).

Где применяем (7. Кто владеет состоянием: lifting state): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

TaskList (владеет tasks)
 ├── TaskItem ← [task]
 ├── StatsBar ← [tasks]
 └── FilterBar ← [filter] и (filterChange)

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Если данные нужны в нескольких независимых ветках дерева, состояние поднимается к их общему предку. Если веток много и дерево глубокое — состояние выносится в сервис (урок 6) с помощью DI.

Не каждый кусочек данных обязан жить наверху. Локальный UI-статус (открыта ли панель, какая вкладка активна внутри одного компонента) храните локально в этом компоненте. Правило применимо к данным, которыми делятся.

Где применяем (7. Кто владеет состоянием: lifting state): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

// локальное состояние — остаётся в компоненте
export class Accordion {
  isOpen = false; // не делится — живёт здесь
}

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Урок 4 • Раздел 8

8. Полный цикл: TaskItem и переключение

Соберём первый полный цикл связи: чекбокс в TaskItem управляет состоянием через TaskList.

Где применяем (8. Полный цикл: TaskItem и переключение): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

// task-item.ts
import { Component, input, output } from '@angular/core';
import type { Task } from '../task';

@Component({
  selector: 'app-task-item',
  standalone: true,
  templateUrl: './task-item.html',
})
export class TaskItem {
  // вход данных сверху
  readonly task = input.required<Task>();
  // выход события вверх
  readonly toggled = output<void>();

  toggle() {
    // сообщаем родителю о намерении, сами данные не меняем
    this.toggled.emit();
  }
}

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Где применяем (8. Полный цикл: TaskItem и переключение): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<!-- task-item.html: шаблон только читает вход и испускает событие -->
<div [class.done]="task().completed">
  <input
    type="checkbox"
    [checked]="task().completed"
    (change)="toggle()"  <!-- клик -> выход toggled -->
    [attr.aria-label]="'Отметить: ' + task().title"
  />
  <span>{{ task().title }}</span>
</div>

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (8. Полный цикл: TaskItem и переключение): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
readonly tasks = signal<Task[]>([...]);

onToggled(id: number) {
  // неизменяемое обновление: создаём новый массив
  this.tasks.update(current =>
    current.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    )
  );
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (8. Полный цикл: TaskItem и переключение): Так Angular перебирает задачи и рисует их на экране.

Аналогия из жизни: @for + track — как рассадка учеников по именным бейджам. Angular знает, какой элемент какому соответствует по стабильному ключу (track task.id), и обновляет только нужного «ученика», а не весь класс.

<!-- task-list.html -->
@for (task of tasks(); track task.id) {
  <li>
    <app-task-item
      [task]="task"
      (toggled)="onToggled(task.id)"
    />
  </li>
}

Разбор по шагам: (1) @for перебирает элементы сигнала; (2) track говорит, по какому ключу узнавать задачу; (3) для каждой рисуется дочерний компонент.

На экране: каждая задача появляется как отдельная строка списка.

Последовательность событий:

  1. пользователь кликает чекбокс;
  2. TaskItem вызывает toggle() и испускает toggled;
  3. TaskList ловит (toggled) и вызывает onToggled(task.id);
  4. map() создаёт новый массив, где у нужной задачи изменён completed;
  5. сигнал обновляется, шаблон перерисовывается.
Ключевая мысль. TaskItem не знает, как хранится список. Он только сообщает «я кликнут». Всю работу с данными делает владелец — TaskList.
Урок 4 • Раздел 9

9. Полный цикл: TaskForm и создание

Создание задачи — второй полный цикл. TaskForm собирает название и передаёт его наверх, TaskList добавляет запись.

Где применяем (9. Полный цикл: TaskForm и создание): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// task-form.ts
import { Component, output } from '@angular/core';

@Component({
  selector: 'app-task-form',
  standalone: true,
  templateUrl: './task-form.html',
})
export class TaskForm {
  newTitle = '';
  // выход несёт строку — название новой задачи
  readonly created = output<string>();

  onInput(event: Event) {
    this.newTitle = (event.target as HTMLInputElement).value;
  }

  submit() {
    if (this.newTitle.trim() === '') {
      return; // guard clause: пустую задачу не создаём
    }
    // испускаем только название; id и объект — забота родителя
    this.created.emit(this.newTitle.trim());
    this.newTitle = '';
  }
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (9. Полный цикл: TaskForm и создание): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<form (submit)="submit()">
  <input [value]="newTitle" (input)="onInput($event)"
         placeholder="Название задачи" aria-label="Название задачи" />
  <button type="submit" [disabled]="newTitle.trim() === ''">Добавить</button>
</form>

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (9. Полный цикл: TaskForm и создание): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
onCreated(title: string) {
  const id = Math.max(0, ...this.tasks().map(t => t.id)) + 1;
  const task: Task = { id, title, completed: false, priority: 'low' };
  this.tasks.update(current => [task, ...current]);
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (9. Полный цикл: TaskForm и создание): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-list.html -->
<app-task-form (created)="onCreated($event)" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Выход output<string>() несёт строку — название новой задачи. Родитель получает её через $event.

Расчёт id. Math.max(0, ...id-ов) + 1 даёт следующий уникальный номер. Это временное решение, пока данные не приходят с сервера (урок 7). Тогда id будет присваивать API.
Урок 4 • Раздел 10

10. Полный цикл: удаление задачи

Удаление — тоже цикл: TaskItem испускает событие, TaskList убирает элемент из массива через filter().

Где применяем (10. Полный цикл: удаление задачи): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// task-item.ts
readonly deleted = output<void>();

deleteTask() {
  this.deleted.emit();
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (10. Полный цикл: удаление задачи): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-item.html -->
<button (click)="deleteTask()" aria-label="Удалить задачу">✕</button>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (10. Полный цикл: удаление задачи): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
onDeleted(id: number) {
  this.tasks.update(current => current.filter(t => t.id !== id));
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (10. Полный цикл: удаление задачи): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-list.html -->
<app-task-item
  [task]="task"
  (toggled)="onToggled(task.id)"
  (deleted)="onDeleted(task.id)"
/>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

filter() возвращает новый массив без удалённой задачи. Поскольку создаётся новый массив (а не мутация), сигнал корректно обнаружит изменение.

Идентификация важна. Событие (deleted) не несёт id — родитель знает, в каком цикле находится, и передаёт task.id сам. Так события остаются простыми, а контекст добавляет родитель.
Урок 4 • Раздел 11

11. Деструктуризация данных в методах

В уроке 2 (раздел 11) мы разобрали деструктуризацию. В методах компонента она удобна для извлечения полей из объекта.

Где применяем (11. Деструктуризация данных в методах): Так ищут, где оборвалась связь между компонентами.

Аналогия из жизни: console.log — как записная книжка следователя: вы оставляете пометки «здесь событие дошло», чтобы понять, на каком шаге оборвалась цепочка. Потом эти пометки убирают.

// без деструктуризации
logTask(task: Task) {
  console.log(task.id, task.title, task.completed);
}

// с деструктуризацией
logTask({ id, title, completed }: Task) {
  console.log(id, title, completed);
}

Разбор по шагам: (1) ставим метку до испускания события; (2) ставим метку в обработчике родителя; (3) смотрим в консоль, на каком шаге нет записи.

На экране: в консоли браузера (F12) видны ваши пометки-шаги.

Паттерн из Angular: при обработке события разбираем payload на нужные поля:

Где применяем (11. Деструктуризация данных в методах): Так ищут, где оборвалась связь между компонентами.

Аналогия из жизни: console.log — как записная книжка следователя: вы оставляете пометки «здесь событие дошло», чтобы понять, на каком шаге оборвалась цепочка. Потом эти пометки убирают.

onCreated(title: string) {
  // title уже извлечён — payload выхода
}

onEdit({ title }: Task) {
  // берём только title из задачи
  console.log('Редактируем:', title);
}

Разбор по шагам: (1) ставим метку до испускания события; (2) ставим метку в обработчике родителя; (3) смотрим в консоль, на каком шаге нет записи.

На экране: в консоли браузера (F12) видны ваши пометки-шаги.

Деструктуризация объекта в параметрах функции — частый паттерн в обработчиках. Она делает сигнатуру ясной: видно, какие поля реально используются.

В шаблоне Angular деструктуризация не используется напрямую. Она применяется в TypeScript-коде классов и функций. В шаблонах доступ к полю идёт через точку: task.title.
Урок 4 • Раздел 12

12. Идентификация: id задачи и track

Когда список рендерится через @for, Angular должен понимать, какой элемент какому соответствует. Для этого служит track.

Аналогия — track (ключ элемента). Это как бейдж с именем у ученика в классе. Если Ваня пересел на другую парту, учитель всё равно знает, что это Ваня, — по бейджу, а не по тому, какое он занял место в ряду. Без бейджа (по номеру в списке) учитель перепутает детей, когда кто-то выйдет из класса. В Angular track task.id — это бейдж: стабильный признак, по которому фреймворк узнаёт задачу, даже если она поменяла позицию в списке.

Где применяем (12. Идентификация: id задачи и track): Так Angular перебирает задачи и рисует их на экране.

Аналогия из жизни: @for + track — как рассадка учеников по именным бейджам. Angular знает, какой элемент какому соответствует по стабильному ключу (track task.id), и обновляет только нужного «ученика», а не весь класс.

@for (task of tasks(); track task.id) {
  <app-task-item [task]="task" (toggled)="onToggled(task.id)" />
}

Разбор по шагам: (1) @for перебирает элементы сигнала; (2) track говорит, по какому ключу узнавать задачу; (3) для каждой рисуется дочерний компонент.

На экране: каждая задача появляется как отдельная строка списка.

Зачем это важно: если задача №2 изменила completed, Angular может обновить только элемент с task.id === 2, не перерисовывая весь список. Без стабильного ключа Angular потерял бы связь между данными и DOM.

Что можно использовать как ключ:

$index — с осторожностью. Если список можно сортировать, фильтровать или удалять, позиция меняется, и Angular перепутает элементы. Используйте $index только для списков с неизменным порядком.

Хорошая практика — всегда задавать id в модели Task и использовать его в track. Это же id используется в onToggled(task.id), onDeleted(task.id).

Урок 4 • Раздел 13

13. События с данными: payload выходов

Выход может нести данные разного объёма: без типа, строку, число, объект.

Где применяем (13. События с данными: payload выходов): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

readonly toggled = output<void>();          // без данных
readonly created = output<string>();          // строка
readonly priorityChanged = output<'high'|'low'>(); // union literal
readonly edited = output<Task>();             // целый объект

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Когда нужно передать больше одного значения, удобно использовать объект (payload):

Где применяем (13. События с данными: payload выходов): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

readonly selection = output<{ id: number; completed: boolean }>();

notify() {
  this.selection.emit({
    id: this.task().id,
    completed: !this.task().completed,
  });
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Родитель читает поля:

Где применяем (13. События с данными: payload выходов): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<app-task-item (selection)="onSelection($event)" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (13. События с данными: payload выходов): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

onSelection(p: { id: number; completed: boolean }) {
  // p.id, p.completed
}

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Сколько данных нести? Чем меньше payload, тем проще. Если события должны сообщить только «случилось действие» — используйте void и подставляйте контекст на стороне родителя (как task.id в цикле). Объект — когда родитель не может сам восстановить данные.
Урок 4 • Раздел 14

14. Выбор событий: что выносить наверх

Не каждый клик нужно выносить наверх. Разделим: какие события принадлежат дочернему компоненту, а какие — его родителю.

СитуацияКому принадлежитПочему
Отметить задачуРодитель (TaskList)Меняет общий список tasks
Удалить задачуРодительМеняет общий список tasks
Создать задачуРодительДобавляет в общий список
Выбрать фильтрРодительФильтр влияет на весь список вниз
Открыть внутренний дропдаунДочерний (TaskItem)Локальный UI-статус
Подсветить hoverДочернийЧисто визуальное состояние

Правило: выносим наверх события, которые меняют данные, общие для нескольких компонентов, или задают внешнее поведение. События чисто внутреннего UI оставляем внутри.

Чтобы решить. Спросите: «если данные изменились, кто должен об этом узнать?». Если несколько компонентов — владелец выше, событие поднимается. Если только сам компонент — остаётся локальным.
Урок 4 • Раздел 15

15. Неизменяемость данных при обновлении

Когда мы меняем список, мы не мутируем старый массив, а создаём новый. Это называется «неизменяемое обновление». Оно нужно, чтобы Angular надёжно заметил изменение.

Где применяем (15. Неизменяемость данных при обновлении): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// создание: новый массив с новой задачей спереди
this.tasks.update(cur => [task, ...cur]);

// удаление: новый массив без задачи
this.tasks.update(cur => cur.filter(t => t.id !== id));

// переключение: новый массив с изменённой копией задачи
this.tasks.update(cur =>
  cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Плохой вариант: мутировать внутренний массив и «надеяться», что Angular заметит.

Где применяем (15. Неизменяемость данных при обновлении): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// плохо — мутация исходного массива
const list = this.tasks();
list.push(task);      // сигнал не заметит изменение
this.tasks.set(list); // ссылка та же — может не сработать корректно

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Лучше всегда создавать новую ссылку через update() с возвратом нового массива. Такой подход согласуется с принципами React и state management из уроков 2 и 21.

Оператор spread { ...t, completed: !t.completed } создаёт копию объекта с изменённым полем, не трогая оригинал (урок 2, раздел 12).

Урок 4 • Раздел 16

16. Двусторонняя связь: банан и model()

Иногда хочется, чтобы дочерний компонент и показывал значение, и мог его менять, а изменение автоматически возвращалось в родителя. Для полей форм это делается «двусторонней» связью. Принятое название — «banana in a box» из-за скобок [( )].

Где применяем (16. Двусторонняя связь: банан и model()): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<input [(ngModel)]="title" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

[( )] — совмещённая запись property binding + event binding. В современных приложениях проще и нагляднее писать два явных биндинга:

Где применяем (16. Двусторонняя связь: банан и model()): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<input [value]="title" (input)="onInput($event)" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Angular также предлагает сигнальную двустороннюю связь через model():

Где применяем (16. Двусторонняя связь: банан и model()): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

// дочерний компонент с модельным входом-выходом
const draft = model<string>('');
// использует как input: draft()
// и как output: draft.set(...) / draft.update(...)

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Где применяем (16. Двусторонняя связь: банан и model()): Так делают двустороннюю связь «показал и тут же поменял».

Аналогия из жизни: model() — это как двусторонний телефон: ребёнок и слушает, что говорит родитель, и может сам позвонить обратно, меняя значение. [(title)] — это «банан в коробке»: одна скобочная пара означает сразу «дал данные» + «принял изменение обратно».

// родитель
<app-title-editor [(draft)]="currentTitle" />

Разбор по шагам: (1) model() — одновременно вход и выход; (2) дочерний читает title() и меняет через title.set(); (3) изменение автоматически улетает родителю через событие titleChange.

На экране: ввод в поле сразу меняет значение и у родителя, и у ребёнка синхронно.

Когда это уместно? Двусторонняя связь удобна для виджетов ввода (поле, редактор). Для серьёзных бизнес-правил лучше явные входы и выходы — так поток данных прозрачнее. На этом уроке достаточно понимать механизм; широко применять будем при изучении форм (урок 8).
Урок 4 • Раздел 17

17. Сравнение: входы/выходы Angular и props/React

Связь компонентов в React устроена так же по принципу, но иначе по синтаксису. Проведём параллель, как в уроке 1 (раздел 24) и уроке 3 (раздел 18).

ЗадачаAngularReact
Данные вниз[task]="task" + input()<TaskItem task={task} /> (props)
Событие вверх(toggled)="..." + output()передача callback-функции onToggled
Чтение в дочернемtask().titletask.title (деструктуризация props)
Двусторонняя[(model)], model()value + onChange
Идентификацияtrackkey

React-компонент-ребёнок получает props и колбэки: function TaskItem({ task, onToggle }). Внутри он вызывает onToggle(id) — это аналог output().

Где применяем (17. Сравнение: входы/выходы Angular и props/React): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// React (позже)
function TaskItem({ task, onToggle }) {
  return <input type="checkbox" checked={task.completed}
                onChange={() => onToggle(task.id)} />;
}

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Общий принцип. В обоих фреймворках состояние поднимается к владельцу, данные уходят вниз, события поднимаются вверх. Освоив ментальную модель в Angular, React-часть покажется знакомой, просто другой синтаксис.
Урок 4 • Раздел 18

18. Композиция TaskFlow: полное дерево

Соберём полное дерево TaskFlow с учётом всех связей из урока.

Где применяем (18. Композиция TaskFlow: полное дерево): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

App
└── TaskList               // владеет tasks и filter
    ├── TaskForm           // output: (created)
    ├── FilterBar          // input: [filter], output: (filterChange)
    ├── StatsBar           // input: [tasks]
    └── TaskItem × N       // input: [task], outputs: (toggled)(deleted)(edited)

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Полное дерево связей TaskFlowAppTaskListвладеет tasks, filterTaskItem × Nвниз: taskвверх: toggled,deleted,editedTaskFormвверх: (created)StatsBarвниз: tasksFilterBarвниз: filter; вверх: filterChangeУсловные обозначениясиний — данные внизоранжевый — события вверхTaskList — единственныйвладелец состояния
Рисунок 3. Каждый дочерний компонент зависит только от входов (синие стрелки) и выходов (оранжевые стрелки) к родителю.

Все данные спускаются от TaskList к дочерним, все события поднимаются обратно к TaskList. Так управлять приложением просто: достаточно смотреть на один компонент-владелец.

💡 Однонаправленный поток. В дереве TaskFlow данные идут вниз через input(), а события — вверх через output(). Родитель владеет состоянием, дети лишь сообщают о намерениях. Это и есть суть архитектуры урока.
▶️ Быстрый запуск. Аналог дерева TaskFlow на чистом JS/HTML: родитель рендерит список дочерних узлов и ловит клик, меняя общее состояние. Angular-версия запускается через ng serve.
Песочница: нажми «Запустить» — и увидишь список задач TaskFlow: клик переключает поле completed (готово/не готово). Меняй код и запускай снова.
Песочница: дерево TaskFlow (аналог на JS)
Урок 4 • Раздел 19

19. Практика 1: связываем TaskItem и TaskList

Начнём сборку. Создадим связь переключения: чекбокс в TaskItem меняет состояние в TaskList.

Где применяем (19. Практика 1: связываем TaskItem и TaskList): Так создаётся и описывается отдельная деталь приложения.

Аналогия из жизни: компонент — как деталь конструктора или запчасть машины. Один файл = одна деталь со своим назначением; из деталей собирают большую машину-приложение.

ng generate component task-item
ng generate component task-list

Разбор по шагам: (1) декоратор @Component задаёт имя тега и файлы; (2) внутри класса — данные и методы; (3) standalone:true позволяет импортировать компонент напрямую.

На экране: готовая деталь встаёт на своё место в приложении.

✍️ Действие

Создайте файлы: src/app/task-item/task-item.ts, src/app/task-item/task-item.html, src/app/task-list/task-list.ts, src/app/task-list/task-list.html.

Где применяем (19. Практика 1: связываем TaskItem и TaskList): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

// task-item.ts
import { Component, input, output } from '@angular/core';
import type { Task } from '../task';

@Component({
  selector: 'app-task-item',
  standalone: true,
  templateUrl: './task-item.html',
  styleUrls: ['./task-item.css'],
})
export class TaskItem {
  readonly task = input.required<Task>();   // данные вниз
  readonly toggled = output<void>();         // событие вверх
  readonly deleted = output<void>();

  toggle() { this.toggled.emit(); }   // сообщаем о переключении
  delete() { this.deleted.emit(); }   // сообщаем об удалении
}

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Где применяем (19. Практика 1: связываем TaskItem и TaskList): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<!-- task-item.html -->
<li class="task-item" [class.done]="task().completed">
  <input
    type="checkbox"
    [checked]="task().completed"
    (change)="toggle()"  <!-- клик -> выход toggled -->
    [attr.aria-label]="'Отметить: ' + task().title"
  />
  <span>{{ task().title }}</span>
  <button class="delete" (click)="delete()" aria-label="Удалить">✕</button>  <!-- клик -> выход deleted -->
</li>

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (19. Практика 1: связываем TaskItem и TaskList): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
import { Component, signal } from '@angular/core';
import { TaskItem } from '../task-item/task-item';
import type { Task } from '../task';

@Component({
  selector: 'app-task-list',
  standalone: true,
  imports: [TaskItem],
  templateUrl: './task-list.html',
  styleUrls: ['./task-list.css'],
})
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Изучить компоненты', completed: true, priority: 'high' },
    { id: 2, title: 'Связать события', completed: false, priority: 'medium' },
  ]);

  onToggled(id: number) {
    // владелец меняет состояние по событию от ребёнка
    this.tasks.update(cur =>
      cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  onDeleted(id: number) {
    // фильтрация создаёт новый массив (без мутации исходного)
    this.tasks.update(cur => cur.filter(t => t.id !== id));
  }
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (19. Практика 1: связываем TaskItem и TaskList): Так Angular перебирает задачи и рисует их на экране.

Аналогия из жизни: @for + track — как рассадка учеников по именным бейджам. Angular знает, какой элемент какому соответствует по стабильному ключу (track task.id), и обновляет только нужного «ученика», а не весь класс.

<!-- task-list.html -->
<ul>
  @for (task of tasks(); track task.id) {
    <app-task-item
      [task]="task"
      (toggled)="onToggled(task.id)"
      (deleted)="onDeleted(task.id)"
    />
  }
</ul>

Разбор по шагам: (1) @for перебирает элементы сигнала; (2) track говорит, по какому ключу узнавать задачу; (3) для каждой рисуется дочерний компонент.

На экране: каждая задача появляется как отдельная строка списка.

Практика 1.

Соберите TaskItem и TaskList. Проверьте: отметить задачу чекбоксом — зачёркивается; удалить кнопкой — исчезает. Обновление состояния происходит в TaskList.

Урок 4 • Раздел 20

20. Практика 2: фильтрация через выбор в FilterBar

Фильтр живёт в TaskList, а выбор происходит в FilterBar. Свяжем их: вниз — выбранный фильтр, вверх — событие смены фильтра.

✍️ Действие

Создайте файлы: src/app/filter-bar/filter-bar.ts, src/app/filter-bar/filter-bar.html, src/app/filter-bar/filter-bar.css.

Где применяем (20. Практика 2: фильтрация через выбор в FilterBar): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

// filter-bar.ts
import { Component, input, output } from '@angular/core';

export type TaskFilter = 'all' | 'active' | 'done';

@Component({
  selector: 'app-filter-bar',
  standalone: true,
  templateUrl: './filter-bar.html',
  styleUrls: ['./filter-bar.css'],
})
export class FilterBar {
  readonly filter = input.required<TaskFilter>();        // текущий фильтр вниз
  readonly filterChange = output<TaskFilter>();           // выбор вверх

  select(value: TaskFilter) {
    // испускаем выбранное значение родителю
    this.filterChange.emit(value);
  }
}

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Где применяем (20. Практика 2: фильтрация через выбор в FilterBar): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<div class="filters">
  <button
    (click)="select('all')"   <!-- выбор -> выход filterChange -->
    [class.active]="filter() === 'all'"
  >Все</button>
  <button
    (click)="select('active')"
    [class.active]="filter() === 'active'"
  >Активные</button>
  <button
    (click)="select('done')"
    [class.active]="filter() === 'done'"
  >Выполненные</button>
</div>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (20. Практика 2: фильтрация через выбор в FilterBar): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts (добавляем фильтр)
readonly filter = signal<TaskFilter>('all');

onFilterChange(value: TaskFilter) {
  // родитель хранит выбранный фильтр
  this.filter.set(value);
}

visibleTasks() {
  const f = this.filter();
  if (f === 'active') return this.tasks().filter(t => !t.completed);
  if (f === 'done') return this.tasks().filter(t => t.completed); // done — значение фильтра
  return this.tasks();
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (20. Практика 2: фильтрация через выбор в FilterBar): Так из общего списка получают только нужные задачи.

Аналогия из жизни: filter() — как ситечко для чая: пропускает только нужное, а остальное оставляет за бортом. В TaskFlow оно отсеивает задачи, которые не подходят под выбранный фильтр, и возвращает новый список.

<!-- task-list.html -->
<app-filter-bar
  [filter]="filter()"
  (filterChange)="onFilterChange($event)"
/>

<ul>
  @for (task of visibleTasks(); track task.id) {
    <app-task-item ... />
  }
</ul>

Разбор по шагам: (1) filter() перебирает элементы; (2) оставляет только те, где условие true; (3) возвращает новый массив без изменённых оригиналов.

На экране: после выбора фильтра в списке остаются только подходящие задачи.

Как работает: FilterBar сообщает о выборе через (filterChange), TaskList сохраняет фильтр в сигнале, а метод visibleTasks() отдаёт отфильтрованный список (применён filter() из урока 2).

Урок 4 • Раздел 21

21. Практика 3: статистика из родителя

StatsBar получает данные (весь список) от родителя и сам вычисляет производные значения.

✍️ Действие

Создайте файлы: src/app/stats-bar/stats-bar.ts, src/app/stats-bar/stats-bar.html.

Где применяем (21. Практика 3: статистика из родителя): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// stats-bar.ts
import { Component, input } from '@angular/core';
import type { Task } from '../task';

@Component({
  selector: 'app-stats-bar',
  standalone: true,
  templateUrl: './stats-bar.html',
})
export class StatsBar {
  readonly tasks = input.required<Task[]>();

  total() { return this.tasks().length; }
  active() { return this.tasks().filter(t => !t.completed).length; }
  done() { return this.tasks().filter(t => t.completed).length; }
}

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (21. Практика 3: статистика из родителя): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<div class="stats">
  <span>Всего: {{ total() }}</span>
  <span>Активных: {{ active() }}</span>
  <span>Выполнено: {{ done() }}</span>
</div>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (21. Практика 3: статистика из родителя): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-list.html -->
<app-stats-bar [tasks]="tasks()" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

StatsBar — «чистый» компонент: только данные на вход, никаких внутренних изменений. Он зависит от родителя лишь через tasks. Переиспользуется где угодно, куда передадут массив.

💡 Чистый компонент = предсказуемость. Из-за однонаправленного потока (данные вниз через input()) StatsBar ничего не ломает в родителе. Его можно тестировать изолированно: подали массив — проверили вывод.
Преимущество подхода. StatsBar не знает, что данные хранятся в сигнале, приходят из сервиса или из API. Ему важен только входной массив. Это слабая связанность — компоненты легко тестировать и менять.
Урок 4 • Раздел 22

22. Практика 4: добавление задачи из формы

TaskForm передаёт новое название наверх через output(). TaskList добавляет задачу в список.

✍️ Действие

Создайте файлы: src/app/task-form/task-form.ts, src/app/task-form/task-form.html, src/app/task-form/task-form.css.

Где применяем (22. Практика 4: добавление задачи из формы): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// task-form.ts
import { Component, output } from '@angular/core';

@Component({
  selector: 'app-task-form',
  standalone: true,
  templateUrl: './task-form.html',
  styleUrls: ['./task-form.css'],
})
export class TaskForm {
  newTitle = '';
  readonly created = output<string>();   // выход несёт название

  onInput(event: Event) {
    this.newTitle = (event.target as HTMLInputElement).value;
  }

  submit() {
    const title = this.newTitle.trim();
    if (title === '') return;            // guard clause
    this.created.emit(title);            // данные вверх родителю
    this.newTitle = '';
  }
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (22. Практика 4: добавление задачи из формы): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<form class="task-form" (submit)="submit()">
  <input
    [value]="newTitle"
    (input)="onInput($event)"   <!-- данные из поля вверх -->
    placeholder="Название новой задачи"
    aria-label="Название задачи"
  />
  <button type="submit" [disabled]="newTitle.trim() === ''">Добавить</button>
</form>

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (22. Практика 4: добавление задачи из формы): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
onCreated(title: string) {
  const id = Math.max(0, ...this.tasks().map(t => t.id)) + 1;
  const task: Task = { id, title, completed: false, priority: 'low' };
  this.tasks.update(cur => [task, ...cur]);
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (22. Практика 4: добавление задачи из формы): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-list.html -->
<app-task-form (created)="onCreated($event)" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Обратите внимание: TaskForm не знает, как строится объект задачи и какой id получит. Он лишь сообщает название. Создание и id — забота TaskList.

Урок 4 • Раздел 23

23. Практика 5: удаление задачи

Удаление: TaskItem испускает deleted, TaskList убирает элемент через filter().

Где применяем (23. Практика 5: удаление задачи): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// task-item.ts (уже есть)
readonly deleted = output<void>();
delete() { this.deleted.emit(); }

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (23. Практика 5: удаление задачи): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts (уже есть)
onDeleted(id: number) {
  this.tasks.update(cur => cur.filter(t => t.id !== id));
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Разберём, почему работает связка (deleted)="onDeleted(task.id)": событие пришло без данных (void), но родитель знает, в каком элементе цикла находится благодаря task.id. Так сочетание «цикл + событие» позволяет родителю восстановить контекст.

Безопасное удаление. «✕» — частое положение для случайного клика. Добавьте подтверждение на уровне продукта позже; здесь важно, что событие поднимается корректно и массив обновляется через новый фильтр (не мутацию).

Также можно добавить «Отменить» для мягкого удаления — но это уже дополнительные события, принцип тот же.

Урок 4 • Раздел 24

24. Практика 6: редактирование (замена объекта)

Редактирование названия — следующий шаг. TaskItem сообщает о необходимости редактирования, TaskList заменяет объект.

Где применяем (24. Практика 6: редактирование (замена объекта)): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// task-item.ts
readonly edited = output<string>();

edit() {
  this.edited.emit(this.task().title);
}

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Где применяем (24. Практика 6: редактирование (замена объекта)): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<button (click)="edit()" aria-label="Редактировать">✎</button>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (24. Практика 6: редактирование (замена объекта)): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts
onEdited(id: number, newTitle: string) {
  this.tasks.update(cur =>
    cur.map(t =>
      t.id === id
        ? { ...t, title: newTitle.trim() || t.title }
        : t
    )
  );
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (24. Практика 6: редактирование (замена объекта)): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- task-list.html -->
<app-task-item
  [task]="task"
  (toggled)="onToggled(task.id)"
  (deleted)="onDeleted(task.id)"
  (edited)="onEdited(task.id, $event)"
/>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

{ ...t, title: newTitle } создаёт новый объект с изменённым заголовком, не трогая остальные поля (spread из урока 2). newTitle.trim() || t.title — если строка пустая после обрезки, оставляем старое название (guard-приём).

Замена vs изменение полей. Мы никогда не пишем t.title = newTitle прямо в массиве. Вместо этого копируем объект через spread и возвращаем новый. Это неизменяемое обновление, о котором говорилось в разделе 15.
Урок 4 • Раздел 24.1

24.1. $event и типизация payload событий

Обработчик события получает либо void (если выход без данных), либо значение, которое испустил дочерний компонент. Это значение называется $event в шаблоне.

Где применяем (24.1. $event и типизация payload событий): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<app-task-item (toggled)="onToggled($event)" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Что именно приходит в $event, определяется типом выхода. Разберём три случая.

Тип выходаЧто в $eventПример обработчика
output<void>()undefinedonToggled() { } — без параметра
output<string>()строкаonCreated(title: string)
output<Task>()объектonEdited(task: Task)

События DOM и события компонентов

Важно различать два уровня $event:

Где применяем (События DOM и события компонентов): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

// DOM-событие: $event — это Event
onInput(event: Event) {
  this.newTitle = (event.target as HTMLInputElement).value;
}

// событие компонента: $event — это title
onCreated(title: string) {
  this.addTask(title);
}

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Не путайте уровни. Если вы слушаете (input) на элементе, $event — DOM-объект, и в нём нет .target-less строки. Если вы слушаете событие компонента, $event — это payload, объявленный в output(). Смотрите, что именно испускает дочерний компонент.

Упрощение через шаблонную ссылку

Чтобы не возиться с event.target, можно взять значение из шаблонной ссылки (раздел 28.2 урока 3):

Где применяем (Упрощение через шаблонную ссылку): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<input #title (input)="onInput(title.value)" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (Упрощение через шаблонную ссылку): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

onInput(value: string) {
  this.newTitle = value;
}

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Это чище, чем читать $event.target: метод принимает уже готовую строку, а не низкоуровневый DOM-объект. Тип параметра становится точным и понятным.

Вывод. Типизируйте параметры ваших обработчиков так же строго, как выходы. Если выход output<TaskFilter>(), обработчик принимает value: TaskFilter. Тогда компилятор заранее поймает несоответствие типов.
Урок 4 • Раздел 24.2

24.2. Почему сигналы: реактивность и перерисовка

Когда мы меняем массив задач через tasks.update(...), Angular автоматически перерисовывает только те места шаблона, которые зависят от этого сигнала. Это и есть реактивность.

Аналогия — сигнал (signal). Сигнал — это переменная с «подписчиками», как группа в мессенджере. Вы меняете значение (пишете сообщение), и все, кто подписан, сразу его получают и реагируют. Шаблон «подписан» на tasks(): стоит списку обновиться — и Angular сам перерисовывает только те места, где этот список показан. Вам не нужно вручную искать на странице нужную строчку и менять её текст.

Механика упрощённо выглядит так:

  1. Создаётся сигнал — контейнер значения с подписчиками.
  2. Шаблон читает сигнал: tasks(), filter(). Angular запоминает эту зависимость.
  3. Когда значение меняется, сигнал уведомляет те участки шаблона, которые его читали.
  4. Angular обновляет только затронутые DOM-узлы, а не всю страницу.

Где применяем (24.2. Почему сигналы: реактивность и перерисовка): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

readonly tasks = signal<Task[]>([]);

updateIsDone(id: number) {
  this.tasks.update(cur =>
    cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
  );
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Заметьте: метод не говорит Angular «перерисуй список». Он просто создаёт новое значение. Обновление DOM происходит автоматически, потому что шаблон зависит от сигнала.

Почему это удобнее старой модели. Раньше разработчик вручную искал конкретный DOM-элемент и менял его класс, текст или класс списка. При любой ошибке синхронизация ломалась. С сигналами уровень «что показать» отделён от «как обновить DOM».
Следующий шаг. В уроке 5 мы добавим computed() — производные значения, которые пересчитываются автоматически, когда меняется источник. Например, visibleTasks() пока мы считаем методом; в уроке 5 его можно будет сделать вычисляемым сигналом.
Урок 4 • Раздел 24.3

24.3. Transform inputs: нормализация входных данных

Иногда входящее значение нужно преобразовать перед использованием. Для этого существует опция transform у input().

Где применяем (24.3. Transform inputs: нормализация входных данных): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly title = input('', { transform: trimTitle });

function trimTitle(value: string) {
  return value.trim();
}

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Теперь, что бы ни передал родитель через [title], дочерний всегда получит значение, обрезанное по краям. Это избавляет от повторяющихся .trim() внутри компонента.

Другой полезный приём — опция initialValueTransform, которая обрабатывает только значение по умолчанию:

Где применяем (24.3. Transform inputs: нормализация входных данных): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

readonly priority = input<'high'|'low'>('low', {
  initialValueTransform: normalizePriority,
});

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Входы с transform полезны для:

Не злоупотребляйте. Transform — удобство, но не место для серьёзной бизнес-логики. Сложные преобразования лучше держать в классе или сервисе. Transform уместен для небольших «переводчиков» формата.

Где применяем (24.3. Transform inputs: нормализация входных данных): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// вход, который принимает число как строку и превращает в number
readonly limit = input(0, {
  transform: (value: string | number) =>
    typeof value === 'string' ? Number(value) : value,
});

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Урок 4 • Раздел 24.4

24.4. model() углублённо: двусторонний вход-выход

В разделе 16 мы увидели model() в двух строках. Теперь разберём его с полным рабочим примером — поле ввода, которое может читать и обновлять значение, а изменение автоматически возвращается родителю.

model() создаёт одновременно и вход, и выход. Дочерний читает значение как сигнал и может обновлять его через set/update. При обновлении значение «уезжает» наверх к родителю через синтетическое событие modelNameChange.

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Так делают двустороннюю связь «показал и тут же поменял».

Аналогия из жизни: model() — это как двусторонний телефон: ребёнок и слушает, что говорит родитель, и может сам позвонить обратно, меняя значение. [(title)] — это «банан в коробке»: одна скобочная пара означает сразу «дал данные» + «принял изменение обратно».

// title-editor.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-title-editor',
  standalone: true,
  templateUrl: './title-editor.html',
})
export class TitleEditor {
  readonly title = model('');

  onInput(event: Event) {
    const v = (event.target as HTMLInputElement).value;
    this.title.set(v);   // обновляет сигнал и уведомляет родителя
  }
}

Разбор по шагам: (1) model() — одновременно вход и выход; (2) дочерний читает title() и меняет через title.set(); (3) изменение автоматически улетает родителю через событие titleChange.

На экране: ввод в поле сразу меняет значение и у родителя, и у ребёнка синхронно.

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

<input [value]="title()" (input)="onInput($event)" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

// title-editor.html
<input [value]="title()" (input)="onInput($event)" />

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Родитель использует двойной биндинг [(title)]:

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Так делают двустороннюю связь «показал и тут же поменял».

Аналогия из жизни: model() — это как двусторонний телефон: ребёнок и слушает, что говорит родитель, и может сам позвонить обратно, меняя значение. [(title)] — это «банан в коробке»: одна скобочная пара означает сразу «дал данные» + «принял изменение обратно».

<app-title-editor [(title)]="heading" />

Разбор по шагам: (1) model() — одновременно вход и выход; (2) дочерний читает title() и меняет через title.set(); (3) изменение автоматически улетает родителю через событие titleChange.

На экране: ввод в поле сразу меняет значение и у родителя, и у ребёнка синхронно.

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

// parent.ts
heading = 'Мои задачи';

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Теперь при вводе пользователя heading в родителе обновится автоматически. Это эквивалентно записи:

Где применяем (24.4. model() углублённо: двусторонний вход-выход): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<app-title-editor [title]="heading" (titleChange)="heading = $event" />

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Правило именования. Для модели title событие называется titleChange. Двойной биндинг [(title)] — это просто сахар для «вход title + выход titleChange».
Когда стоит задуматься. Двусторонняя связь удобна, когда дочерний — «виджет ввода» и действительно владеет редактируемым значением. Для задач с бизнес-правилами (проверка, нормализация, асинхронность) явные input()/output() читаются лучше. Выбор зависит от характера компонента.
Урок 4 • Раздел 24.5

24.5. Связь «братьев» через общего родителя

Два компонента-«брата» (FilterBar и TaskItem) не общаются напрямую: у них нет прямого канала. Вместо этого они общаются через общего родителя — TaskList.

Братья общаются через родителяTaskListобщий родитель, владелецFilterBarвверх: (filterChange)TaskItemвниз: [filter] через видимость
Рисунок. FilterBar сообщает о выборе наверх, TaskList меняет фильтр, TaskItem получает другой набор задач через вниз.

Цепочка выглядит так:

  1. FilterBar испускает (filterChange) с новым значением;
  2. TaskList ловит событие и обновляет сигнал filter;
  3. visibleTasks() возвращает отфильтрованный список;
  4. TaskItem получает другую задачу (или теряет её) через [task].

Обратите внимание: ни FilterBar, ни TaskItem не знают о существовании друг друга. Весь «мост» — в родителе. Это ключевая идея: «братья» общаются только через общего родителя.

Запомните. Прямая связь между двумя «братьями» нарушила бы единое направление потока данных. Всё, что меняет общее состояние, проходит через владельца. Компонент становится переиспользуемым: он не зависит от конкретного «соседа».
Урок 4 • Раздел 24.6

24.6. Дизайн событий: именование, payload, деструктуризация

Хорошие события — такие, по названию которых сразу понятно намерение, а по типу — какие данные они приносят. Это часть «архитектурной гигиены».

Именование выходов

ПлохоХорошоПочему
changefilterChangeясно, что и с чем меняется
taptaskSelectedимя отражает предмет
dotoggledпрошедшее время — событие уже случилось

Принято называть событие в прошедшем времени или как факт: toggled, created, deleted, filterChange. Это сигнал о том, что действие произошло и родитель реагирует.

Payload: сколько данных

Правило из раздела 13: минимальный payload. Если родитель может сам восстановить контекст (например, task.id в цикле), несите мало данных.

Где применяем (Payload: сколько данных): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// выход без данных — контекст у родителя
readonly toggled = output<void>();

// выход с одной важной величиной
readonly filtered = output<TaskFilter>();

// выход с объектом — когда нужны несколько полей
readonly edited = output<{ id: number; title: string }>();

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Деструктуризация payload в обработчике

Когда объект-контейнер небольшой, его удобно разложить прямо в сигнатуре метода:

Где применяем (Деструктуризация payload в обработчике): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

onEdited({ id, title }: { id: number; title: string }) {
  this.applyEdit(id, title);
}

applyEdit(id: number, title: string) {
  this.tasks.update(cur =>
    cur.map(t => t.id === id ? { ...t, title } : t)
  );
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Это перекликается с заданием 6 самостоятельной работы и с деструктуризацией из урока 2 (раздел 11). Приём делает обработчик коротким и точным.

Итог. Три вопроса при проектировании события: что произошло (имя), какие данные нужны родителю (payload), как их восстановить (минимизируй и раскладывай). Ответив на них, вы построите понятный интерфейс компонента.
Урок 4 • Раздел 24.7

24.7. Аргументы @for: $index, $count, контекст в событиях

Внутри @for Angular даёт несколько специальных переменных. Они доступны только внутри тела цикла и позволяют добавить контекст в событие.

ПеременнаяЗначение
$indexпозиция элемента (с 0)
$countобщее число элементов
$firsttrue для первого элемента
$lasttrue для последнего элемента
$even / $oddчётность позиции

Где применяем (24.7. Аргументы @for: $index, $count, контекст в событиях): Так Angular перебирает задачи и рисует их на экране.

Аналогия из жизни: @for + track — как рассадка учеников по именным бейджам. Angular знает, какой элемент какому соответствует по стабильному ключу (track task.id), и обновляет только нужного «ученика», а не весь класс.

@for (task of visibleTasks(); track task.id; let i = $index) {
  <app-task-item
    [task]="task"
    [attr.data-index]="i"
    (moved)="onMoved($event, i)"
  />
}

Разбор по шагам: (1) @for перебирает элементы сигнала; (2) track говорит, по какому ключу узнавать задачу; (3) для каждой рисуется дочерний компонент.

На экране: каждая задача появляется как отдельная строка списка.

Здесь $event приносит функцию, а i — позицию из цикла. Родитель получает и то, и другое. Так можно передавать в событие дополнительный контекст, который сам дочерний компонент не знает.

Где применяем (24.7. Аргументы @for: $index, $count, контекст в событиях): Так ищут, где оборвалась связь между компонентами.

Аналогия из жизни: console.log — как записная книжка следователя: вы оставляете пометки «здесь событие дошло», чтобы понять, на каком шаге оборвалась цепочка. Потом эти пометки убирают.

onMoved(fn: (tasks: Task[]) => Task[], index: number) {
  // используем и результат выхода, и позицию из цикла
  console.log('Позиция:', index);
}

Разбор по шагам: (1) ставим метку до испускания события; (2) ставим метку в обработчике родителя; (3) смотрим в консоль, на каком шаге нет записи.

На экране: в консоли браузера (F12) видны ваши пометки-шаги.

Осторожно с $index как ключом. В разделе 12 мы уже говорили, что $index ненадёжен как track при изменении порядка. Но как дополнительная информация в событии (например, номер для отображения) он допустим. Главное — не использовать его как единственный стабильный идентификатор.

Где применяем (24.7. Аргументы @for: $index, $count, контекст в событиях): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

// показать номер перед задачей (только для отображения)
<span class="num">{{ $index + 1 }}</span>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Синтаксис let i = $index даёт локальную ссылку на позицию. Это удобно, когда внутри @for требуется несколько раз сослаться на номер элемента.

Урок 4 • Раздел 24.8

24.8. Доступность в событиях: a11y и клавиатура

События — это не только клики мышью. Хорошее приложение должно работать с клавиатуры и читаться скринридерами. Свяжем события с доступностью.

Семантические элементы

Чекбокс — это <input type="checkbox">, а не просто <div> с обработчиком клика. Кнопка — <button>, а не <span>. Так браузер сам даёт клавиатурную навигацию (Enter/Space) и скринридеру — роль элемента.

Где применяем (Семантические элементы): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- правильно: настоящая кнопка -->
<button class="delete" (click)="delete()" aria-label="Удалить задачу">✕</button>

<!-- неправильно: div с кликом -->
<div class="delete" (click)="delete()">✕</div>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

aria-label для иконок

Иконки без текста («✕», «✎») непонятны скринридеру. Добавляйте aria-label:

Где применяем (aria-label для иконок): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<button (click)="delete()" aria-label="Удалить задачу">✕</button>
<button (click)="edit()" aria-label="Редактировать задачу">✎</button>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Текущее состояние через aria

Для переключения чекбокса состояние видно и так. Для фильтров активное состояние удобно сообщить скринридеру:

Где применяем (Текущее состояние через aria): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<button
  (click)="select('done')"
  [class.active]="filter() === 'done'"
  [attr.aria-pressed]="filter() === 'done'"
>Выполненные</button>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

aria-pressed сообщает скринридеру, что кнопка «нажата» — это аналог визуального класса active.

Правило. Каждое событие пользователя должно иметь доступный эквивалент: правильный элемент, подпись и (при необходимости) атрибут состояния. Доступность — часть дизайна событий, а не отдельная задача после.
Урок 4 • Раздел 24.9

24.9. Отладка потока данных между компонентами

Когда событие не доходит или список не обновляется, отладка сводится к трём вопросам: где данные, кто владелец, в каком направлении движется событие.

Алгоритм отладки

  1. Поймите владельца. Где объявлен signal? Если список не меняется, возможно, обновляется не тот компонент.
  2. Проверьте, что событие испускается. Добавьте временный console.log в метод дочернего и в обработчик родителя.
  3. Проверьте, что значение пришло. Выведите $event: console.log($event) прямо в связке.
  4. Проверьте неизменяемость. Если вы мутировали массив, сигнал мог не заметить изменение.
  5. Проверьте track. Неправильный ключ вызывает перерисовку не того элемента.

Где применяем (Алгоритм отладки): Так ищут, где оборвалась связь между компонентами.

Аналогия из жизни: console.log — как записная книжка следователя: вы оставляете пометки «здесь событие дошло», чтобы понять, на каком шаге оборвалась цепочка. Потом эти пометки убирают.

// временная диагностика в связке
<app-task-item
  [task]="task"
  (toggled)="log('toggled', $event); onToggled(task.id)"
/>

Разбор по шагам: (1) ставим метку до испускания события; (2) ставим метку в обработчике родителя; (3) смотрим в консоль, на каком шаге нет записи.

На экране: в консоли браузера (F12) видны ваши пометки-шаги.

Где применяем (Алгоритм отладки): Так ищут, где оборвалась связь между компонентами.

Аналогия из жизни: console.log — как записная книжка следователя: вы оставляете пометки «здесь событие дошло», чтобы понять, на каком шаге оборвалась цепочка. Потом эти пометки убирают.

log(tag: string, value: unknown) {
  console.log(tag, value);
}

Разбор по шагам: (1) ставим метку до испускания события; (2) ставим метку в обработчике родителя; (3) смотрим в консоль, на каком шаге нет записи.

На экране: в консоли браузера (F12) видны ваши пометки-шаги.

Не оставляйте console.log в коде. Диагностика нужна на время. Перед завершением удалите временные вызовы или оставьте только осмысленное логирование.

Чтение через DevTools

В браузере Angular предлагает панель DevTools: можно посмотреть дерево компонентов, какие входы переданы и какие сигналы меняются. Это ускоряет поиск «кто кому что передал».

Если панели нет, визуально пометьте, что происходит: временно добавьте [class.active] или цвет на изменяемый элемент — и посмотрите, отрабатывает ли связка.

Главный приём. Подозрительное соединение разбирайте по шагам: испускается ли событие → доходит ли до родителя → меняется ли состояние → перерисовывается ли шаблон. Один из четырёх этапов выдаст причину.
Урок 4 • Раздел 24.10

24.10. Собираем всё: соединение TaskFlow

Соберём все изученные приёмы в один связный компонент TaskList, который владеет данными и соединяет дочерние компоненты.

Где применяем (24.10. Собираем всё: соединение TaskFlow): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// task-list.ts — полный владелец состояния
import { Component, signal } from '@angular/core';
import { TaskItem } from '../task-item/task-item';
import { TaskForm } from '../task-form/task-form';
import { FilterBar, type TaskFilter } from '../filter-bar/filter-bar';
import { StatsBar } from '../stats-bar/stats-bar';
import type { Task } from '../task';

@Component({
  selector: 'app-task-list',
  standalone: true,
  imports: [TaskItem, TaskForm, FilterBar, StatsBar],
  templateUrl: './task-list.html',
})
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Изучить сигналы', completed: true, priority: 'high' },
    { id: 2, title: 'Соединить компоненты', completed: false, priority: 'medium' },
  ]);
  readonly filter = signal<TaskFilter>('all');

  visibleTasks() {
    const f = this.filter();
    if (f === 'active') return this.tasks().filter(t => !t.completed);
    if (f === 'done') return this.tasks().filter(t => t.completed); // done — значение фильтра
    return this.tasks();
  }

  onCreated(title: string) {
    const id = Math.max(0, ...this.tasks().map(t => t.id)) + 1;
    const task: Task = { id, title, completed: false, priority: 'low' };
    this.tasks.update(cur => [task, ...cur]); // новый массив спереди
  }

  onToggled(id: number) {
    // переключаем completed через неизменяемое обновление
    this.tasks.update(cur =>
      cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  onDeleted(id: number) {
    this.tasks.update(cur => cur.filter(t => t.id !== id));
  }

  onEdited(id: number, title: string) {
    this.tasks.update(cur =>
      cur.map(t => (t.id === id ? { ...t, title: title.trim() || t.title } : t))
    );
  }

  onFilterChange(value: TaskFilter) {
    this.filter.set(value); // храним выбор фильтра у владельца
  }
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Где применяем (24.10. Собираем всё: соединение TaskFlow): Так из общего списка получают только нужные задачи.

Аналогия из жизни: filter() — как ситечко для чая: пропускает только нужное, а остальное оставляет за бортом. В TaskFlow оно отсеивает задачи, которые не подходят под выбранный фильтр, и возвращает новый список.

<!-- task-list.html -->
<app-task-form (created)="onCreated($event)" />

<app-filter-bar
  [filter]="filter()"
  (filterChange)="onFilterChange($event)"
/>

<app-stats-bar [tasks]="tasks()" />

<ul>
  @for (task of visibleTasks(); track task.id) {
    <app-task-item
      [task]="task"
      (toggled)="onToggled(task.id)"
      (deleted)="onDeleted(task.id)"
      (edited)="onEdited(task.id, $event)"
    />
  }
</ul>

Разбор по шагам: (1) filter() перебирает элементы; (2) оставляет только те, где условие true; (3) возвращает новый массив без изменённых оригиналов.

На экране: после выбора фильтра в списке остаются только подходящие задачи.

Это полный владелец состояния: он хранит tasks и filter, обрабатывает все события, формирует видимый список и передаёт данные вниз.

Прочитайте и убедитесь. Каждое событие здесь меняет состояние в TaskList, а не в дочернем компоненте. Каждый вход передаёт данные, а не логику. Имя каждого выхода отражает намерение. Это и есть зрелая связь компонентов, которую вы будете строить и в React-части курса.
Урок 4 • Раздел 24.11

24.11. Когда не поднимать состояние вверх

Правило «хранить у общего родителя» не безусловно. Иногда состояние вообще не нужно «таскать» через цепочку компонентов — это усложняет передачу. Обсудим два ограничения.

Состояние, которое никому не нужно, кроме одного компонента

Если значение используют только внутри одного компонента и оно не влияет на других, поднимать его не нужно. Например, открытая ли панель фильтра, текущая вкладка внутри одного виджета, позиция скролла — это локальный UI-статус.

Где применяем (Состояние, которое никому не нужно, кроме одного компонента): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

// локальное состояние остаётся локальным
export class FilterBar {
  isDropdownOpen = false; // никому снаружи не нужно
  toggleDropdown() {
    this.isDropdownOpen = !this.isDropdownOpen;
  }
}

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Глубокая передача через много уровней (prop drilling)

Если данные нужно передать через пять уровней вложенности только ради одного листа дерева, ручная передача через входы превращается в «prop drilling»: промежуточные компоненты вынуждены принимать и прокидывать значения, которые им самим не нужны.

Где применяем (Глубокая передача через много уровней (prop drilling)): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

App
└── Workspace
    └── Panel
        └── Section
            └── TaskList   // хочет user-идентификатор

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Когда цепочка становится глубокой, состояние выносится в сервис с помощью внедрения зависимостей (DI). Тогда TaskList получает данные напрямую, без прокидывания через промежуточные компоненты.

Где применяем (Глубокая передача через много уровней (prop drilling)): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// сервис-призрак (детально в уроке 6)
@Injectable({ providedIn: 'root' })
export class TaskStore {
  readonly tasks = signal<Task[]>([]);
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Баланс. Для неглубокого дерева поднятие состояния к родителю — самый простой путь. Когда передача становится глубокой и многоступенчатой, сервис (DI) даёт более чистую архитектуру. На этом уроке мы работаем с неглубоким деревом TaskFlow, поэтому поднятие состояния оптимально; сервисы разберём в уроке 6.
Не списывайте всё на поднятие. «Поднять повыше» — не универсальное решение. Если каждый кусочек данных уносить в самый верх, корневые компоненты разбухнут. Спрашивайте: насколько глубоко и широко эти данные реально используются.
Урок 4 • Раздел 24.12

24.12. Чтение legacy-кода: @Input/@Output и EventEmitter

В разделах 3 и 5 мы упомянули старый синтаксис. Теперь соберём полный пример legacy-компонента, чтобы уверенно читать существующие проекты.

Где применяем (24.12. Чтение legacy-кода: @Input/@Output и EventEmitter): Так родитель передаёт задачу ребёнку и как ребёнок её объявляет.

Аналогия из жизни: вход — это почтовый ящик ребёнка. Родитель опускает туда данные (как письмо: «возьми хлеб»), а ребёнок только достаёт и читает их — сам ничего не кладёт и не меняет. В Angular родитель пишет [task]="...", а ребёнок читает это через input() — и получает значение как сигнал, который Angular отслеживает на изменения (данные идут вниз).

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-task-item',
  standalone: true,
  template: `
    <input type="checkbox" [checked]="task.completed"
           (change)="toggle.emit()" />
    <span>{{ task.title }}</span>
  `,
})
export class TaskItem {
  @Input() task!: Task;
  @Output() toggle = new EventEmitter<void>();
}

Разбор по шагам: (1) input() создаёт вход-сигнал; (2) required значит «родитель обязан передать»; (3) в шаблоне читаем вызовом task(), в коде — this.task().

На экране: ребёнок показывает то, что передал родитель, — например, название задачи.

Старый кодНовый кодРазница
@Input() task!: Taskreadonly task = input.required<Task>()декоратор vs функция; чтение через task()
@Output() toggle = new EventEmitter()readonly toggle = output()экземпляр vs функция; emit() в обоих
в шаблоне: task.titleв шаблоне: task().titleскобка вызова сигнала

Ключевые отличия, которые важно видеть при чтении старого кода:

Зачем уметь читать legacy. Существующие проекты Angular часто содержат старый синтаксис. Вы должны понимать оба, чтобы поддерживать такой код, но писать новые компоненты — на современном input()/output().
Урок 4 • Раздел 24.13

24.13. Практика 7: соединить форму, фильтр и список в один экран

Финальное упражнение связывает всё: форма вверху, фильтр, статистика и список задач ниже — и все они общаются через один TaskList.

Где применяем (24.13. Практика 7: соединить форму, фильтр и список в один экран): Так из общего списка получают только нужные задачи.

Аналогия из жизни: filter() — как ситечко для чая: пропускает только нужное, а остальное оставляет за бортом. В TaskFlow оно отсеивает задачи, которые не подходят под выбранный фильтр, и возвращает новый список.

<!-- task-list.html — полный экран -->
<section class="task-flow">
  <h2>TaskFlow</h2>
  <app-task-form (created)="onCreated($event)" />
  <app-filter-bar
    [filter]="filter()"
    (filterChange)="onFilterChange($event)"
  />
  <app-stats-bar [tasks]="tasks()" />
  <ul class="task-list">
    @for (task of visibleTasks(); track task.id) {
      <app-task-item
        [task]="task"
        (toggled)="onToggled(task.id)"
        (deleted)="onDeleted(task.id)"
        (edited)="onEdited(task.id, $event)"
      />
    }
  </ul>
</section>

Разбор по шагам: (1) filter() перебирает элементы; (2) оставляет только те, где условие true; (3) возвращает новый массив без изменённых оригиналов.

На экране: после выбора фильтра в списке остаются только подходящие задачи.

Проверьте сценарии в браузере:

ДействиеОжидаемый результат
Ввести название и «Добавить»новая задача появляется сверху списка
Отметить чекбоксзадача зачёркивается, статистика меняется
Выбрать «Выполненные»в списке остаются только выполненные
Нажать ✕задача исчезает, счётчики обновляются
Изменить названиезаголовок заменяется новым

Если хотя бы один сценарий не сработал, примените алгоритм из раздела 24.9: найдите, где рвётся цепочка — событие, состояние или перерисовка.

Критерий успеха. Настроив один-единственный компонент владельца (TaskList) так, что все пять сценариев работают, вы подтверждаете понимание полного цикла связи. Это именно то, чего требует курс от урока 4.
Готовность к React. Этот же набор сценариев вы повторите на React (уроки 14–16). Ментальная модель «данные вниз, события вверх» одинакова; изменится только синтаксис. Если сейчас вы поняли принцип на Angular, перенос будет быстрым.
Урок 4 • Раздел 24.14

24.14. Переиспользование компонента: один компонент, разные места

Сила входов и выходов — в том, что один компонент можно вставить в разные места и настроить через входы, не меняя его код. Проверим это на FilterBar.

Допустим, мы хотим изменить подписи кнопок через входы, чтобы переиспользовать FilterBar и для другого набора фильтров.

Где применяем (24.14. Переиспользование компонента: один компонент, разные места): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

// filter-bar.ts — настраиваемый через входы
readonly filter = input.required<TaskFilter>();
readonly filterChange = output<TaskFilter>();
readonly labels = input<{ all: string; active: string; done: string }>({
  all: 'Все',
  active: 'Активные',
  done: 'Выполненные',
});

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Где применяем (24.14. Переиспользование компонента: один компонент, разные места): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<button (click)="select('all')" [class.active]="filter() === 'all'">
  {{ labels().all }}
</button>
<button (click)="select('active')" [class.active]="filter() === 'active'">
  {{ labels().active }}
</button>
<button (click)="select('done')" [class.active]="filter() === 'done'">
  {{ labels().done }}
</button>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Где применяем (24.14. Переиспользование компонента: один компонент, разные места): Фрагмент из рабочего цикла TaskFlow.

Аналогия из жизни: любой фрагмент кода здесь — это одна маленькая операция в общем конвейере TaskFlow. Прочитайте его как шаг в цепочке «данные вниз, события вверх».

<!-- в другом месте — другие подписи без изменения компонента -->
<app-filter-bar
  [filter]="filter()"
  (filterChange)="onFilterChange($event)"
  [labels]="{ all: 'Все записи', active: 'Открытые', done: 'Закрытые' }"
/>

Разбор по шагам: читайте код сверху вниз — каждая строка делает один маленький шаг в общей цепочке передачи данных или события.

На экране: результат этого шага встроен в общий экран TaskFlow.

Идея в том, что интерфейс компонента (input()/output()) — это его «контракт». Пока контракт стабилен, компонент переиспользуется в разных местах, адаптируясь через входы.

Контракт vs реализация. Внешние наблюдатели видят только входы и выходы — публичный контракт. Внутренняя реализация (как посчитан класс active, каким сигналом хранится data) скрыта. Это упрощает тестирование и замену реализации без изменения мест использования.

Тестируемость чистого компонента

Поскольку компонент зависит только от входов и выходов, его легко проверить: подали вход → ожидаем выход. Никакой внешней среды не требуется.

Где применяем (Тестируемость чистого компонента): Так дочерний компонент сообщает родителю о действии пользователя.

Аналогия из жизни: выход — это когда ребёнок зовёт родителя: «Мам, я нажал кнопку!». Он не решает за родителя, что делать дальше, — только сообщает факт. В Angular ребёнок вызывает this.toggled.emit(), а родитель, услышав (toggled), сам решает, как изменить данные.

// идея теста (подробнее в уроке 12)
const filterBar = new FilterBar();
// имитируем, что родитель передал
// [filter]="'all'" → нет прямого доступа, но через host
// взаимодействие: клик по кнопке → filterChange.emit('done')

Разбор по шагам: (1) output() объявляет выход; (2) emit() испускает событие; (3) родитель слушает через (имяВыхода)="метод($event)" и реагирует.

На экране: когда пользователь кликает, родитель реагирует (задача зачёркивается, исчезает и т.п.).

Слабо связанные компоненты («данные на вход, событие на выход») — основа тестируемости и переиспользования. Эта же идея лежит в основе «чистых компонентов», которые мы рассмотрим в React-части.

Урок 4 • Раздел 25

25. Самостоятельная работа

Шесть заданий на отработку связи компонентов. Сначала попробуйте сами, затем сверьтесь с подсказками в разделе 26.

Задание 1 — Подсветка выбранного фильтра

В FilterBar кнопка выбранного фильтра должна подсвечиваться классом active. Используйте вход [filter].

Задание 2 — Сброс поля после создания

После created.emit(title) очистите поле newTitle и убедитесь, что инпут пустеет.

Задание 3 — Счётчик для FilterBar

Добавьте в FilterBar числа у каждой кнопки: «Все (3)» и т.п. Передайте их через входы с дефолтами.

Задание 4 — Отмена удаления

Добавьте кнопку «Отменить» в TaskList после создания задачи; при клике последняя добавленная задача удаляется.

Задание 5 — Сортировка по приоритету

В task-list добавьте метод sortedTasks(), который возвращает высокие приоритеты первыми (примените sort на копии).

Задание 6 — Деструктуризация в обработчике

Перепишите onEdited так, чтобы извлечь id и title из объекта через параметр-деструктуризацию в отдельном методе.

Урок 4 • Раздел 26

26. Подсказки к самостоятельной работе

Совет: сначала сравните свою идею, затем открывайте код.

Задание 1. На каждой кнопке: [class.active]="filter() === 'all'" (соответственно 'active', 'done').
Задание 2. В submit() после created.emit(...) добавьте this.newTitle = '';. Поле обновляется, так как [value]="newTitle".
Задание 3. Входы с дефолтами: allCount = input(0), activeCount = input(0), doneCount = input(0), вывести {{ allCount() }}.
Задание 4. В TaskList храните последний добавленный id и метод undoLast(), фильтрующий его. Кнопку — в TaskList над списком.
Задание 5. sortedTasks() { return [...this.tasks()].sort((a, b) => (a.priority === 'high' ? -1 : 0) - (b.priority === 'high' ? -1 : 0)); } — копия через spread перед sort, чтобы не мутировать сигнал.
Задание 6. Метод applyEdit({ id, title }: Task) внутри onEdited после получения данных, с деструктуризацией параметра.
Урок 4 • Раздел 27

27. Частые ошибки связи компонентов

Разберём типичные проблемы при соединении компонентов.

СимптомПричинаРешение
«Can't bind to 'task'»Дочерний не объявил вход или не импортирован.Добавьте input() и импорт в imports.
«Property 'toggled' does not exist»Родитель слушает выход, которого нет.Объявите output() в дочернем.
Список не обновляетсяМутация массива без новой ссылки.Используйте update() с новым массивом.
Неверный элемент изменяетсяНеправильный id или track по $index при изменении порядка.Используйте track task.id и передавайте корректный id.
Событие не доходитОпечатка в имени выхода или binding на другого ребёнка.Сверьте имена выхода и ( )-биндинга.
Вход «undefined»Передаёте [task], но значение не определено.Проверьте источник данных; добавьте @if вокруг использования.
Следите за направлением. Событие «вверх» обрабатывает родитель, а не дочерний. Если дочерний сам меняет переданный объект — нарушена архитектура. Исправляйте поток: дочерний только сообщает через выход.
Урок 4 • Раздел 28

28. Резюме: как соединить компоненты

Все связи этого урока сводятся к небольшому числу правил.

Пять правил связи компонентов1. Данные внизinput() + [ ]2. События вверхoutput() + ( )3. Состояние у владельцаобщий родитель4. Новый массивспред, map, filter — не мутация5. id + trackстабильный ключ элементаСоветясные имена событий, малый payload
Рисунок 4. Связь компонентов на практике: пять правил, которые стоит заучить.

Последний взгляд на весь урок. Связь компонентов — это не синтаксис, а способ мышления: вы заранее решаете, кто владеет данными, кто сообщает о событиях и как значения перетекают по дереву. Когда такое мышление становится автоматическим, вы можете собрать из деталей целое приложение TaskFlow и уверенно перейти к React, где идея «данные вниз, события вверх» повторяется почти один в один.

Урок 4 • Раздел 29

29. Контрольные вопросы

  1. Как родитель передаёт данные дочернему компоненту в современном Angular?
  2. Что делает input.required<Task>()?
  3. Чем выход output() отличается от входа input() по направлению?
  4. Что такое «данные вниз, события вверх» и зачем это правило?
  5. Где должен храниться список задач, если его показывают TaskItem, StatsBar и FilterBar?
  6. Как обновить массив задач, не мутируя его, — приведите пример для переключения completed?
  7. Зачем нужен track, и почему $index опасен при фильтрации?
  8. Что такое payload выхода, и когда он полезен?
  9. Назовите событие, которое должно остаться локальным (не подниматься наверх).
  10. Какова роль $event в обработчике события?
  11. В чём разница между $event от DOM-события и от события компонента?
  12. Как «братья» (TaskItem и FilterBar) обмениваются информацией?
  13. Когда применение model() уместно, а когда лучше явные входы и выходы?
  14. Приведите пример события, которое должно остаться локальным, и объясните почему.
Урок 4 • Раздел 30

30. Мини-словарь

ТерминОпределение
Вход (input)Данные, передаваемые родителем дочернему компоненту.
Выход (output)Событие, испускаемое дочерним компонентом для родителя.
Property binding [ ]Привязка значения из класса к свойству (данные вниз).
Event binding ( )Привязка события к методу (событие вверх).
«Данные вниз, события вверх»Архитектурный принцип направления связи компонентов.
Lifting state upПоднятие состояния к общему родителю.
trackСтабильный ключ элемента в @for.
Неизменяемое обновлениеСоздание нового массива/объекта вместо мутации.
PayloadДанные, которые несёт событие выхода.
model()Сигнальный вход-выход для двусторонней связи.
Владелец состоянияКомпонент, в котором хранится и изменяется состояние.
transform inputФункция, преобразующая входное значение перед использованием.
$eventЗначение, которое несёт событие (DOM-событие или payload выхода).
«Братья» (siblings)Компоненты, имеющие общего родителя; общаются через него.
Contract (контракт)Совокупность входов и выходов, видимая внешним компонентам.
aria-label / aria-pressedАтрибуты доступности для описания и состояния элементов.
Prop drillingСитуация глубокой передачи данных через много уровней вложенности.
Урок 4 • Раздел 31

31. Источники и продолжение

Для самостоятельного углубления:

Следующий шаг. В уроке 5 мы заменим ручные геттеры и пересчёты на computed() и effect() — полноценную реактивность Angular. Это сделает связь компонентов ещё более отзывчивой: производные значения будут пересчитываться автоматически при изменении источников.
Урок 4 • Раздел 32

32. Чек-лист урока

Готовность к уроку 5. Если вы можете выполнить все задания раздела 25 и объяснить поток данных для любого события TaskFlow, основа связи компонентов усвоена. Урок 5 добавит реактивные производные значения через computed и effect.

🛠 Практика к уроку 4

Свяжите дерево компонентов: данные вниз через входы, события вверх через выходы.

Задание 1. TaskItem: вход и выход

Где применяем (Задание 1. TaskItem: вход и выход): Так выглядит компонент, у которого есть и вход (данные сверху), и выходы (крик наверх).

Аналогия из жизни: это как пара «почтовый ящик ребёнка (данные вниз) + зовёт родителя (событие вверх)». Родитель опускает в ящик данные (вход), а ребёнок, когда что-то случилось, зовёт родителя (выход). Ребёнок данные не правит — только сообщает о факте, а родитель решает, что делать.

export class TaskItem {
  task = input.required<Task>();
  toggle = output<void>();
}

Разбор по шагам: (1) родитель пишет [task]="..." — данные идут вниз; (2) ребёнок объявляет input.required и output; (3) при действии ребёнок вызывает emit(), и родитель в (toggled) ловит событие и меняет данные.

На экране: видите список задач, у каждой — чекбокс и кнопка удаления; всё работает через передачу данных и событий.

Задание 2. Поднимаем событие вверх

Где применяем (Задание 2. Поднимаем событие вверх): Так хранится и меняется общий список задач у владельца.

Аналогия из жизни: сигнал — это переменная с подписчиками, как группа в мессенджере. Меняете значение — и все, кто подписан (шаблон), сразу получают обновление и перерисовываются. Вам не нужно вручную трогать страницу.

// TaskPage ловит событие и меняет состояние
onToggle(id: number) {
  this.tasks.update(list =>
    list.map(t => t.id === id
      ? { ...t, completed: !t.completed } : t));
}

Разбор по шагам: (1) signal хранит массив; (2) update() строит НОВЫЙ массив; (3) Angular видит новое значение и перерисовывает только зависимые места.

На экране: изменили одну задачу — обновилась только она, остальное на месте.

Полная версия. Практика: Урок 4 → — дерево AppShell → TaskPage → TaskList → TaskItem, FilterBar и песочница событий.
Куда дальше: закрепи связь компонентов в практике к уроку 4, а про сигналы и состояние — в Уроке 5.

📖 Глоссарий

ТерминЖизненная аналогияКоротко
Дерево компонентовМатрёшка / семейное деревоБольшая матрёшка внутри содержит поменьше; «родитель» разместил «ребёнка».
input (вход)Почтовый ящик ребёнкаРодитель опускает данные вниз; ребёнок только достаёт и читает — сам не меняет.
output (выход)Ребёнок зовёт родителяДочерний кричит вверх «я нажал!» и не решает за родителя, что делать.
СобытиеФакт, а не приказСообщение о том, что случилось; обрабатывает его тот, кто владеет данными.
СигналГруппа в мессенджереПеременная с подписчиками: изменил значение — все подписанные места перерисовались.
Task / TaskDraftГотовый билет / черновик анкетыTask — билет с id и completed; TaskDraft — черновик без номера, ещё не принятый.

🧠 Самопроверка

1. Дочерний компонент сам меняет данные родителя напрямую через input. Верно или неверно?

2. input() отвечает за «данные вниз», а output() — за «события вверх». Верно или неверно?

3. Сигнал похож на группу в мессенджере: при изменении значения все подписанные места сразу узнают обновление. Верно или неверно?

Прогресс курса