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

Урок №5

Angular: сигналы и реактивное состояние

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

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

Сигналы: источник и производные значенияsignal() — источникreadonly tasks = signal([])события меняютonToggled → update()новый массив каждый разчитаетcomputed()фильтры, счётчикиvisibleTasks()activeCount()пересчитывается сампроизводныйШаблон@for (visibleTasks()){{ activeCount() }}перерисовка нужной части
Рисунок 1. Источник-сигнал и производные computed-значения: шаблон читает результат, а не хранит дублирующее состояние.
Главная идея урока. Состояние TaskFlow живёт в сигнале, а всё, что из него «вычисляется» (фильтры, счётчики, поиск), — в computed(). Урок 4 показал, как компоненты общаются; урок 5 — как Angular автоматически пересчитывает производные значения и перерисовывает только нужное.

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

Содержание

  1. 1. Почему реактивность: от состояния к производным значениям
  2. 2. Обзор темы: что мы уже знаем и что добавим
  3. 3. signal(): создание, чтение и запись
  4. 4. set() и update(): два способа обновить сигнал
  5. 5. Writable и readonly сигналы
  6. 6. computed(): производные значения
  7. 7. Ленивость и кэширование computed
  8. 8. effect(): побочные эффекты
  9. 9. Когда эффекты уместны, а когда нет
  10. 10. Чтение сигналов в шаблоне
  11. 11. Перерисовка: когда Angular обновляет DOM
  12. 12. Неизменяемость массивов: map, filter, spread
  13. 13. Неизменяемость объектов: spread
  14. 14. Фильтры через computed: все, активные, выполненные
  15. 15. Счётчики через computed: производные от списка
  16. 16. Поиск через find и reduce в computed
  17. 17. Сортировка через computed
  18. 18. Каскад computed: производное от производного
  19. 19. Сравнение: методы-геттеры и computed
  20. 20. Полный цикл TaskFlow с сигналами
  21. 21. Связь с React: useState, useMemo, производные
  22. 22. Осторожно: мутации и сигналы
  23. 23. Практика 1: сигнал списка задач
  24. 24. Практика 2: toggle через computed
  25. 25. Практика 3: фильтры через computed
  26. 26. Практика 4: счётчики через computed
  27. 27. Практика 5: поиск через find
  28. 28. Практика 6: статистика и каскад computed
  29. 28.1. Сигналы для отдельных полей: checkbox и форма
  30. 28.2. Поток данных: снова «данные вниз, события вверх»
  31. 28.3. Схема реактивности: отклик на изменение
  32. 28.4. Эффекты на практике: сохранение и логирование
  33. 28.5. Производная статистика через reduce
  34. 28.6. Чтение старого кода: subscription и BehaviorSubject
  35. 28.7. Что такое контекст инъекций и почему эффекты в компоненте
  36. 28.8. Сравнение сигналов в Angular и React
  37. 28.9. Computed от нескольких сигналов
  38. 28.10. Модель Task: readonly id и неизменяемые поля
  39. 28.11. Передача сигнала в дочерний компонент
  40. 28.12. Структура сигнала: примитив, массив, объект
  41. 28.13. Вынос чистой логики в функции
  42. 28.14. Автодополнение и prefill через computed
  43. 28.15. Синхронизация фильтра и видимости
  44. 28.16. Поиск по строке: ещё один computed
  45. 28.17. Когда пора переходить на сервисы
  46. 28.18. Проверка реактивности: маленькие тесты
  47. 28.19. Производительность больших списков
  48. 28.20. Обратная связь: события изменяют сигнал
  49. 28.21. @let и локальные переменные из computed
  50. 28.22. Опциональные фильтры и значения по умолчанию
  51. 28.23. Практика 7: поиск + фильтр + сортировка в одном экране
  52. 28.24. TaskForm на сигналах: полный сценарий
  53. 28.25. Архитектурный итог: реактивное состояние как система
  54. 29. Самостоятельная работа
  55. 30. Подсказки к самостоятельной работе
  56. 31. Частые ошибки реактивности
  57. 32. Резюме: как устроено реактивное состояние
  58. 33. Контрольные вопросы
  59. 34. Мини-словарь
  60. 35. Источники и продолжение
  61. 36. Чек-лист урока
Урок 5 • Раздел 1

1. Почему реактивность: от состояния к производным значениям

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

💡 Аналогия. Состояние. Состояние — это как статус вашего заказа в интернет-магазине: «в обработке / готов / ошибка». Оно одно и меняется со временем; всё остальное (например, «сколько осталось собрать») считается из него. Определение: состояние (state) — это данные, которые хранятся в программе и могут меняться.

Состояние TaskFlow — это список задач tasks и выбранный фильтр filter. А вот «сколько задач выполнено», «какие задачи показывать при фильтре "активные"», «есть ли задача с данным id» — это уже производные значения: они автоматически следуют из состояния и не хранятся отдельно.

// состояние — храним
readonly tasks = signal<Task[]>([]);
readonly filter = signal<TaskFilter>('all');

// производное — вычисляем из состояния
computed(() => this.tasks().filter(t => t.completed)); // выполненные

Почему это важно? Если вы храните в отдельной переменной счётчик «выполнено» и не обновите его при изменении списка, он устареет. Производные значения исключают эту ошибку: они всегда соответствуют состоянию, потому что пересчитываются из него.

Запомните. Никогда не храните данные, которые можно вычислить из уже существующих. Это дублирование — источник рассогласования. Вместо него используйте computed().

В уроке 4 мы уже использовали signal() и методы-геттеры вроде visibleTasks(). Урок 5 заменит ручной пересчёт на автоматический реактивный механизм.

Урок 5 • Раздел 2

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

К этому моменту у вас есть база: signal() для состояния (урок 1, 4), методы для вычисления (урок 4) и неизменяемые обновления через map/filter/spread (урок 2). Урок 5 систематизирует реактивность.

КонструкцияЧто делали раньшеЧто добавим в уроке 5
signal()хранение списка и фильтратипы, readonly, set/update
Геттер visibleTasks()ручной пересчёт при вызовеcomputed() — автоматический пересчёт
Метод total()вычисление на каждый запросcomputed() со кэшированием
Побочные действияeffect() для side effects
Перерисовка«сигнал изменился — шаблон поменялся»почему и когда Angular перерисовывает
Связность. Урок 4 соединил компоненты. Урок 5 делает состояние «живым»: производные значения обновляются сами, без ручного вызова методов. Это опора для сервисов (урок 6) и HTTP (урок 7).
Урок 5 • Раздел 3

3. signal(): создание, чтение и запись

Сигнал — это контейнер, который хранит значение и уведомляет о его изменении. Создаётся через signal(), читается вызовом (), меняется через set()/update().

💡 Аналогия. Сигнал. Сигнал — это переменная с «подписчиками», как группа в мессенджере: когда вы меняете значение (присылаете сообщение), все подписчики получают уведомление и обновляются сами. Определение: сигнал — это контейнер, который хранит значение и уведомляет о его изменении всех, кто за ним следит.

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

💡 Аналогия. Чтение сигнала — как спросить группу «какое сейчас значение?»; запись через set() — как объявить новое значение всем подписчикам.
import { signal } from '@angular/core';

const count = signal(0);      // создать со стартовым значением 0
console.log(count());          // прочитать — вызов без аргументов
count.set(5);                  // записать новое значение
console.log(count());          // 5
Разбор по шагам: Шаг 1 — signal(0) создаёт сигнал со стартовым значением 0. Шаг 2 — count() читает текущее значение (0). Шаг 3 — count.set(5) записывает 5 и уведомляет подписчиков. Шаг 4 — повторное чтение даёт 5.

Что увидит пользователь: в консоли сначала 0, потом 5.

Сигнал типизирован. Тип выводится из начального значения, но его можно задать явно:

Контекст. В TaskFlow список задач — это массив, поэтому тип сигнала Task[]. TypeScript проверит, что вы не запишете туда что-то другое.

💡 Аналогия. Тип сигнала — как этикетка на банке: «сюда кладём только яблоки». Если попытаться положить грушу — банка (компилятор) возмутится.
const title = signal<string>('Задача');
const tasks = signal<Task[]>([]);
Разбор по шагам: Шаг 1 — title будет хранить только строки. Шаг 2 — tasks будет хранить только массив задач. Любая попытка записать другое вызовет ошибку на этапе компиляции.

В компоненте сигналы объявляют как поля:

Контекст. Здесь сигнал tasks живёт внутри компонента TaskList, а метод addOne() добавляет задачу неизменяемо (подробно — в разделе 12).

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Изучить сигналы', completed: false },
  ]);

  addOne() {
    // update получает ТЕКУЩИЙ массив (cur) и возвращает НОВУЮ ссылку
    this.tasks.update(cur => [...cur, newTask]);
  }
}
Разбор по шагам: Шаг 1 — поле tasks инициализируется одной задачей. Шаг 2 — при вызове addOne Angular берёт текущий массив cur. Шаг 3 — через spread [...cur, newTask] создаётся новый массив (новая ссылка). Шаг 4 — сигнал «видит» новую ссылку и уведомляет подписчиков.

Что увидит пользователь: в списке появится новая задача без ручного обновления.

Чтение — вызов функции. В TypeScript пишете this.tasks(). В шаблоне тоже пишете tasks() со скобками. Это главное отличие от обычного поля: сигнал — это функция, которая возвращает текущее значение.
▶️ Быстрый запуск. Аналог на чистом JS/HTML показывает «сигнал» как переменную с подписчиками: при set эффект (подписчик) пересчитывает отображение. Angular-версия — через ng serve.
Песочница: нажми «Запустить» — и увидишь простой сигнал с подписчиками на чистом JS: кнопка «+1» меняет значение, а эффект тут же перерисовывает вывод. Меняй код и запускай снова.
Песочница: простой сигнал с подписчиками (аналог на JS)
Урок 5 • Раздел 4

4. set() и update(): два способа обновить сигнал

У любому записываемому сигналу есть два метода изменения значения.

Контекст. Оба метода меняют значение сигнала, но по-разному: set() заменяет целиком, update() строит новое на основе старого. Это пригодится, когда в TaskFlow мы будем переключать фильтр или менять задачу.

💡 Аналогия. set() — как сказать «поставьте термостат на 22 градуса» (новое значение известно). update() — как сказать «сделай на 1 градус теплее», потому что надо знать текущее.
// set() — присвоить целиком
this.filter.set('done');

// update() — вычислить новое на основе текущего
this.filter.update(current => current === 'all' ? 'done' : 'all');
Разбор по шагам: Шаг 1 — this.filter.set('done') сразу записывает значение 'done' (оно не зависит от старого). Шаг 2 — update получает текущее значение в переменную current. Шаг 3 — если было 'all', возвращаем 'done', иначе 'all'. Шаг 4 — сигнал уведомляет подписчиков.

Что увидит пользователь: список задач перестроится под выбранный фильтр.

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

СлучайПодходитПример
Присвоить новый фильтрset()filter.set('active')
Переключить completedupdate()update(t => ({ ...t, completed: !t.completed }))
Добавить в массивupdate()update(a => [...a, task])
Удалить из массиваupdate()update(a => a.filter(x => x.id !== id))
Правило выбора. Если значение зависит от текущего — update(). Если нет — set(). update() перестраховывает от устаревшего значения, поэтому для массивов и объектов чаще используют его.
Урок 5 • Раздел 5

5. Writable и readonly сигналы

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

// внутри компонента — можно изменять
private readonly tasks = signal<Task[]>([]);

// снаружи — только читать
readonly tasksSignal = this.tasks.asReadonly();

asReadonly() возвращает сигнал без методов set/update. Это защищает данные от случайного изменения из других мест.

// компонент хранит state, но отдаёт только для чтения
export class TaskList {
  private readonly tasks = signal<Task[]>([]);
  readonly tasksRO = this.tasks.asReadonly();

  toggle(id: number) {
    this.tasks.update(cur => ...); // можно — метод внутри
  }
}
Зачем это. Модификация состояния через специальные методы — предсказуемее, чем произвольные set() из любого места. Readonly-грань ограничивает круг тех, кто может менять значение, — контроль архитектуры.

В шаблоне нельзя вызвать set(). Шаблон только читает сигналы; изменения происходят в методах. Это согласуется с принципом «данные вниз» из урока 4.

Урок 5 • Раздел 6

6. computed(): производные значения

Метод computed() создаёт сигнал, значение которого вычисляется из других сигналов. Когда источники меняются, производное значение пересчитывается автоматически.

Контекст. Самый простой пример: удвоенный счётчик. Когда меняется count, doubled пересчитывается сам.

💡 Аналогия. Computed — это производное значение, как итоговый счёт в игре: он считается из других чисел и обновляется сам, стоит им измениться.
import { computed, signal } from '@angular/core';

const count = signal(2);
const doubled = computed(() => count() * 2);

console.log(doubled()); // 4
count.set(5);
console.log(doubled()); // 10 — автоматически
Разбор по шагам: Шаг 1 — создаём сигнал count со значением 2. Шаг 2 — doubled «запоминает», что зависит от count(). Шаг 3 — при чтении doubled() получаем 2×2=4. Шаг 4 — count.set(5) меняет источник, и doubled() теперь даёт 10.

Что увидит пользователь: в консоли 4, затем 10 — без ручного пересчёта.

Внутри computed мы читаем сигналы. Angular запоминает эти зависимости и знает, когда пересчитать.

Контекст. Здесь производное значение — список активных задач, который зависит от сигнала tasks.

💡 Аналогия. computed — это производное значение, как итоговый счёт в игре: он считается из списка задач (tasks) и обновляется сам, стоит тому измениться.
const tasks = signal<Task[]>([]);
// computed читает сигнал tasks() и запоминает зависимость
const activeTasks = computed(() => tasks().filter(t => !t.completed));

// activeTasks() всегда отражает текущее состояние списка
Разбор по шагам: Шаг 1 — пустой сигнал задач. Шаг 2 — activeTasks читает tasks() и оставляет только невыполненные. Шаг 3 — Angular фиксирует зависимость. Шаг 4 — при любом изменении списка результат обновляется.

Внутри computed можно читать несколько сигналов и даже другие computed:

Контекст. Фильтр задач: что показывать — зависит и от списка, и от выбранного фильтра.

💡 Аналогия. Это как итоговый счёт в игре, который считается сразу из двух чисел: списка задач и выбранного фильтра. Поменяли фильтр — итог пересчитался сам.
const filter = signal<TaskFilter>('all');
const visibleTasks = computed(() => {
  const f = filter();
  if (f === 'active') return tasks().filter(t => !t.completed);
  if (f === 'done') return tasks().filter(t => t.completed);
  return tasks();
});
Разбор по шагам: Шаг 1 — читаем текущий фильтр f. Шаг 2 — если 'active', оставляем задачи, где completed ложно. Шаг 3 — если 'done', оставляем выполненные. Шаг 4 — иначе возвращаем весь список. Результат пересчитывается сам при смене фильтра или списка.
Computed — это сигнал. Результат computed() читается как сигнал (visibleTasks()) и не имеет set() — вы не назначаете производное значение вручную, оно вычисляется.
▶️ Быстрый запуск. Аналог на JS показывает computed в виде функции, которая пересчитывает производное значение при каждом изменении источника. Angular-версия — через ng serve.
Песочница: нажми «Запустить» — и увидишь, как computed считает активные задачи: кнопка «Добавить задачу» меняет список, а итоговый счёт пересчитывается сам. Меняй код и запускай снова.
Песочница: computed — производное значение (аналог на JS)
Урок 5 • Раздел 7

7. Ленивость и кэширование computed

Computed-сигналы ленивы и кэшируются. Это два ключевых свойства производительности.

Контекст. Покажем на примере «тяжёлого» computed, который пишет в консоль при каждом реальном пересчёте, чтобы увидеть кэширование в действии.

💡 Аналогия. Кэш — как заранее приготовленный обед: пока продукты (исходники) не менялись, вы просто разогреваете готовое, а не готовите заново. Ленивость — вы не готовите обед, пока никто не голоден (не читает значение).
const heavy = computed(() => {
  console.log('пересчёт');
  return tasks().filter(...).length;
});

heavy(); // «пересчёт» — первое чтение
heavy(); // тишина — используется кэш
tasks.update(...); // источник изменился
heavy(); // «пересчёт» — снова вычислено
Разбор по шагам: Шаг 1 — первый вызов heavy() реально считает и печатает «пересчёт». Шаг 2 — второй вызов берёт закэшированный результат, печати нет. Шаг 3 — меняем источник tasks. Шаг 4 — следующий вызов снова пересчитывает и печатает.

Что увидит пользователь: в консоли «пересчёт» появится только при реальном изменении данных, а не при каждом чтении.

Это выгодно отличает computed от обычного метода-геттера, который пересчитывается при каждом вызове.

Практический эффект. Если шаблон несколько раз читает visibleTasks() за одну перерисовку, а список не менялся, повторные чтения не тратят ресурсы — они берут кэш. Следите за тем, чтобы внутри computed не было побочных действий (подробнее в разделе 8).
Урок 5 • Раздел 8

8. effect(): побочные эффекты

effect() выполняет функцию каждый раз, когда меняется любой сигнал, прочитанный внутри неё. Это место для побочных эффектов: запись в localStorage, отправка в сервис, логирование.

💡 Аналогия. Эффект. Эффект — это как будильник: он не хранит данные, а «срабатывает» каждый раз, когда меняется то, за чем следит. Определение: effect() — функция, которая выполняется при изменении любого прочитанного внутри неё сигнала; место для побочных действий (сохранение, логи, сеть).

Контекст. Самый простой эффект — пишет в консоль при каждом изменении фильтра.

import { effect, signal } from '@angular/core';

const filter = signal('all');

effect(() => {
  console.log('Текущий фильтр:', filter());
});
// при первом запуске: «Текущий фильтр: all»
filter.set('done');
// «Текущий фильтр: done» — сработал снова
Разбор по шагам: Шаг 1 — создаём сигнал filter. Шаг 2 — effect читает filter() и сразу выполняется (печатает «all»). Шаг 3 — filter.set('done') меняет значение. Шаг 4 — эффект видит изменение и печатает «done».

Что увидит пользователь: в консоли две записи: сначала про «all», потом про «done».

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

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

💡 Аналогия. localStorage — как шкаф у входа: вы кладёте туда вещи (данные), и они ждут вас, даже когда вы вышли из комнаты (закрыли вкладку).
// сохранение в localStorage при изменении списка
effect(() => {
  // читаем this.tasks() → эффект «подписан» на этот сигнал
  localStorage.setItem('tasks', JSON.stringify(this.tasks()));
});
Разбор по шагам: Шаг 1 — эффект читает this.tasks(), значит «подписан» на этот сигнал. Шаг 2 — при первом запуске он сразу сохраняет текущий список. Шаг 3 — при любом изменении списка эффект срабатывает снова и перезаписывает хранилище.

Что увидит пользователь: сам по себе — ничего на экране, но данные сохраняются «за кадром» и восстановятся после перезагрузки.

Не пишите в сигналы внутри effect без необходимости. Если эффект читает a() и пишет в b(), а какая-то другая связка ведёт обратно к a(), может возникнуть цикл. Держите эффекты простыми и односторонними.
Урок 5 • Раздел 9

9. Когда эффекты уместны, а когда нет

Эффекты решают узкую задачу. Чтобы не злоупотребить, применяйте их по чёткому признаку: побочное действие, не участвующее в формировании UI.

УместноНе уместно
Запись в localStorageПересчёт производных значений (это computed)
Отправка данных аналитикиИзменение других сигналов «по цепочке»
Логирование измененийПодготовка данных для шаблона
Инициализация внешнего APIСинхронизация двух одинаковых копий состояния

Главное правило: для производных значений, которые влияют на отображение, используйте computed(), а не эффект, меняющий другую переменную. Эффект не должен «переворачивать» состояние ради перерисовки.

// ПЛОХО: эффект синхронизирует дубль
const a = signal(1);
const b = signal(a());
effect(() => b.set(a() * 2)); // лишний сигнал и цикл

// ХОРОШО: computed выражает зависимость напрямую
const a2 = signal(1);
const b2 = computed(() => a2() * 2);
Запомните. UI = состояние + computed(). Эффекты — только для «побочностей» вовне (хранилище, сеть, логи). Если вы поймали себя на изменении сигнала в effect ради перерисовки — переведите это в computed.
Урок 5 • Раздел 10

10. Чтение сигналов в шаблоне

В шаблоне сигнал читается вызовом со скобками, как и в TypeScript-коде.

Контекст. В шаблоне Angular пишем те же вызовы, что и в коде: {{ ...() }}. Это то, что видит пользователь (DOM).

💡 Аналогия. Шаблон — как витрина магазина: она показывает текущее состояние, но сама товары не хранит. Когда состояние меняется, витрина обновляется.
<h2>Задач: {{ tasks().length }}</h2>
<p>Фильтр: {{ filter() }}</p>
<!-- computed тоже вызывается -->
<p>Активных: {{ activeCount() }}</p>
Разбор по шагам: Шаг 1 — {{ tasks().length }} читает сигнал и берёт длину. Шаг 2 — {{ filter() }} показывает текущий фильтр. Шаг 3 — {{ activeCount() }} читает производное значение. Все три обновляются сами при изменении.

Что увидит пользователь: заголовок с числом задач, текущий фильтр и счётчик активных, которые меняются в реальном времени.

Для списка через @for:

Контекст. Цикл перебирает элементы производного списка visibleTasks() и для каждого рисует дочерний компонент задачи.

💡 Аналогия. @for — как конвейер на почте: для каждой посылки (задачи) выполняется одно и то же действие (показать карточку).
@for (task of visibleTasks(); track task.id) {
  <app-task-item [task]="task" />
}
Разбор по шагам: Шаг 1 — visibleTasks() возвращает отфильтрованный массив. Шаг 2 — @for проходит по каждому элементу. Шаг 3 — track task.id помогает Angular быстро обновлять список, зная уникальный ключ. Шаг 4 — для каждой задачи рисуется app-task-item.

Что увидит пользователь: список карточек задач, соответствующий текущему фильтру.

N.B. В уроке 4 мы передавали [task]="task" из цикла. Здесь visibleTasks() — computed, который читается в шаблоне один раз, а внутри цикла task — уже элемент массива.

Скобки обязательны. {{ tasks }} — ошибка; нужно {{ tasks() }}. Сигнал — функция, поэтому и в шаблоне её вызывают. Это главная разница с обычным полем компонента.

Когда источник меняется, Angular перерисовывает только те части шаблона, которые прочитали изменившийся сигнал (подробнее — разделы 11 и 28.3).

Урок 5 • Раздел 11

11. Перерисовка: когда Angular обновляет DOM

В традиционном подходе разработчик вручную находил элемент и менял его. С сигналами Angular сам определяет, что изменилось, и обновляет только затронутые узлы.

Механизм в общих чертах:

  1. Компонент создаёт сигнал состояния.
  2. Шаблон читает сигнал при рендере. Angular запоминает, какой фрагмент от какого сигнала зависит.
  3. Кто-то меняет сигнал через set()/update().
  4. Angular знает, что значение изменилось, и обновляет только зависимые фрагменты DOM.

Контекст. Две строки зависят от разных сигналов — Angular обновит только ту, что изменилась.

💡 Аналогия. DOM. DOM — это дерево/скелет страницы, как этажи дома: есть главный ствол и ветки-дети. Меняя сигнал, мы меняем только нужную «комнату», а не весь дом. Определение: DOM — это структура страницы, которую Angular обновляет, когда меняются сигналы.
<p>Всего: {{ tasks().length }}</p>     <!-- зависит от tasks -->
<p>Фильтр: {{ filter() }}</p>        <!-- зависит от filter -->
Разбор по шагам: Шаг 1 — первая строка «подписана» на tasks. Шаг 2 — вторая «подписана» на filter. Шаг 3 — если поменялся только фильтр, обновится вторая строка.

Что увидит пользователь: при смене фильтра поменяется текст фильтра, а число задач останется прежним (и наоборот).

Если поменяется только filter, Angular обновит вторую строку, а первую не тронет. Это «тонкая» перерисовка, точечная, а не полная перезагрузка страницы.

Почему это важно для архитектуры. Вы описываете что показать (декларативно), а не как обновить (императивно). Урок 1 (раздел 12) называл это «declarative UI». Сигналы — реализация этой схемы в Angular.

Существует также классический механизм change detection (CD). Современные сигналы интегрируются с ним, но точную стратегию перерисовки вы можете влиять (например, onPush, урок 13). Для этого урока достаточно понимать идею зависимости «шаблон ← сигнал».

Урок 5 • Раздел 12

12. Неизменяемость массивов: map, filter, spread

Чтобы Angular заметил изменение, нужно создать новую ссылку на массив, а не мутировать старую. Это мы уже применяли в уроке 4; теперь закрепим как основу реактивности.

Контекст. Три базовые операции со списком задач: добавить, удалить, изменить один элемент — каждая возвращает НОВЫЙ массив (поэтому реактивность срабатывает).

💡 Аналогия. Неизменяемое обновление. Представьте, что вы храните фотографию. Менять её «на месте» (закрасить) — это мутация: те, кто смотрел копию, не заметят правки. Сделать новый снимок с правкой — это новая ссылка: все видят обновление. Неизменяемость — это всегда «новый снимок».
// ДОБАВИТЬ — новый массив с новой задачей
this.tasks.update(cur => [...cur, task]);

// УДАЛИТЬ — новый массив без задачи
this.tasks.update(cur => cur.filter(t => t.id !== id));

// ИЗМЕНИТЬ ОДИН ЭЛЕМЕНТ — map, создающий новые объекты
this.tasks.update(cur =>
  cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
Разбор по шагам: Шаг 1 — [...cur, task] копирует старые задачи и добавляет новую (новая ссылка). Шаг 2 — filter создаёт массив без нужной задачи (новая ссылка). Шаг 3 — map для нужной задачи делает копию через spread, меняя только completed; остальные оставляет как есть. Шаг 4 — сигнал видит новую ссылку и уведомляет подписчиков.

Что увидит пользователь: задача появится/исчезнет/переключится, и счётчики пересчитаются сами.

Плохо: менять массив «на месте».

Контекст. Показываем, почему «правка на месте» ломает реактивность: ссылка не меняется, и Angular «думает», что ничего не произошло.

Плохо — мутация. push меняет массив внутри, но ссылка остаётся прежней.
// ПЛОХО — мутация, сигнал не узнает об изменении
const list = this.tasks();
list.push(task);
// ссылка та же — Angular может не перерисовать
Разбор по шагам: Шаг 1 — this.tasks() возвращает сам массив (ссылку). Шаг 2 — push добавляет элемент внутрь, но ссылка не меняется. Шаг 3 — сигнал «думает», что ничего не изменилось, и DOM не обновляется.

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

Ключ к пониманию: сравнение ссылок. Новый массив — новая ссылка — сигнал «увидел» изменение. Тот же массив с изменённым содержимым — старая ссылка — реактивность может пропустить изменение.

💡 Почему неизменяемость критична для реактивности. Angular отслеживает изменение сигнала по ссылке, а не по содержимому. Если изменить массив «на месте» (push/sort/присваивание поля), ссылка останется прежней — сигнал «решит», что ничего не изменилось, и DOM не перерисуется. Новая ссылка (spread/map/filter) — это для Angular сигнал «сработал». Поэтому неизменяемое обновление — не стиль, а обязательное условие работы реактивности.
Это правило — не только про массивы. Оно применимо и к объектам. Следующий раздел покажет spread для объектов, а урок 2 (раздел 12) дал базу.
Урок 5 • Раздел 13

13. Неизменяемость объектов: spread

Объект внутри массива тоже обновляют копированием через spread, а не присваиванием полю.

Контекст. Переключение флага completed у конкретной задачи — классический пример неизменяемого обновления объекта.

💡 Аналогия. Spread (...). Spread — как ксерокопия документа, где вы меняете один пункт: оригинал не трогаем, делаем копию с правкой. Так старые данные остаются целыми, а сигнал видит новую ссылку.
// переключить completed в конкретной задаче
this.tasks.update(cur =>
  cur.map(t =>
    t.id === id ? { ...t, completed: !t.completed } : t
  )
);
Разбор по шагам: Шаг 1 — map проходит по каждой задаче. Шаг 2 — для нужной (t.id === id) создаём НОВЫЙ объект через { ...t, completed: !t.completed }. Шаг 3 — остальные задачи возвращаются без изменений. Шаг 4 — весь массив — новая ссылка.

Здесь { ...t, completed: !t.completed } создаёт новый объект: сначала распространяются все поля t, затем переопределяется completed.

Контекст. Spread позволяет менять сразу несколько полей или добавлять новые, не трогая оригинал.

// изменить несколько полей
{ ...task, title: newTitle, completed: true }

// добавить поле (изменить форму объекта)
{ ...task, priority: 'high' }
Разбор по шагам: Шаг 1 — { ...task } копирует все поля оригинала. Шаг 2 — перечисленные после него поля (title, completed, priority) перезаписывают копию. Шаг 3 — результат — новый объект, старый не изменился.

Старый объект не меняется — он остаётся в памяти, пока нужен. Это делает обновления безопасными и соответствующими принципу неизменяемости из урока 2 и 15 урока 4.

Не только сигналы. Этот же паттерн понадобится в React (useState, уроки 15–16) и в сервисах (урок 6). Неизменяемое обновление — общий язык реактивных UI.
Урок 5 • Раздел 14

14. Фильтры через computed: все, активные, выполненные

Классическая задача TaskFlow — показать список по фильтру. Реализуем её через computed на основе методов из урока 2 (раздел 9).

Контекст. Один computed visibleTasks решает, какие задачи показывать в зависимости от выбранного фильтра. Значение фильтра — только 'all' | 'active' | 'done'.

💡 Аналогия. filter(). Метод filter() — как ситечко для чая: пропускает только то, что подходит под условие, а остальное оставляет за бортом.
type TaskFilter = 'all' | 'active' | 'done';

const tasks = signal<Task[]>([...]);
const filter = signal<TaskFilter>('all');

const visibleTasks = computed(() => {
  const f = filter();
  if (f === 'active') return tasks().filter(t => !t.completed);
  if (f === 'done') return tasks().filter(t => t.completed);
  return tasks();
});
Разбор по шагам: Шаг 1 — читаем текущий фильтр f. Шаг 2 — если 'active', оставляем задачи, где completed ложно. Шаг 3 — если 'done', оставляем выполненные. Шаг 4 — иначе возвращаем весь список. Результат пересчитывается сам при смене фильтра или списка.

Что увидит пользователь: список подстроится под выбранный фильтр без перезагрузки страницы.

Шаблон использует visibleTasks():

Контекст. В шаблоне просто перебираем уже готовый отфильтрованный список.

@for (task of visibleTasks(); track task.id) {
  <app-task-item [task]="task" />
}
Разбор по шагам: Шаг 1 — visibleTasks() возвращает массив, отобранный computed. Шаг 2 — @for рисует карточку для каждой задачи. Шаг 3 — при смене фильтра computed даёт новый массив, шаблон перерисовывается.

Что увидит пользователь: ровно те задачи, которые соответствуют фильтру.

В уроках 4–5 filter() — это вызов сигнала, а не метод. Две разные сущности: сигнал filter (тип TaskFilter) и метод массива filter(). Не путайте имена.

Почему computed, а не метод. Геттер visibleTasks() в уроке 4 пересчитывался при каждом вызове и должен был вызываться вручную. Computed пересчитывается только при изменении источников и отдаёт результат мгновенно. Это и есть реактивность.
Урок 5 • Раздел 15

15. Счётчики через computed: производные от списка

Счётчики «всего / активных / выполнено» — чистые производные от списка.

Контекст. Три счётчики — это по одному computed на каждый показатель. Они считаются из списка задач.

💡 Аналогия. Счётчики — это производные значения, как итоговый счёт в игре: они показывают итог (сколько всего, сколько активных), который сам пересчитывается, когда меняются данные матча.
const total = computed(() => tasks().length);
// производное: пересчитывается только при изменении tasks()
const activeCount = computed(() => tasks().filter(t => !t.completed).length);
const doneCount = computed(() => tasks().filter(t => t.completed).length);
Разбор по шагам: Шаг 1 — total берёт длину списка. Шаг 2 — activeCount считает задачи с completed === false. Шаг 3 — doneCount считает выполненные. Все три пересчитываются сами при изменении tasks.

В шаблоне статистика:

Контекст. В шаблоне просто выводим значения счётчиков — они всегда актуальны.

<div class="stats">
  <span>Всего: {{ total() }}</span>
  <span>Активных: {{ activeCount() }}</span>
  <span>Выполнено: {{ doneCount() }}</span>
</div>
Разбор по шагам: Шаг 1 — {{ total() }} читает первый счётчик. Шаг 2 — {{ activeCount() }} и {{ doneCount() }} читают остальные. Шаг 3 — при отметке задачи счётчики меняются сами.

Что увидит пользователь: три числа, которые меняются сами при отметке задач (например, отметили — «Выполнено» растёт, «Активных» падает).

Счётчики автоматически следуют за списком. Отметьте задачу — doneCount() вырастет, activeCount() уменьшится без единой строчки «обновления счётчика».

Урок 4 реализовывал это методами total()/active()/done() в StatsBar. Теперь те же вычисления — readonly computed, их можно вынести куда угодно и они следят за источником сами.
Урок 5 • Раздел 16

16. Поиск через find и reduce в computed

Помимо filter, полезны find и reduce (урок 2, раздел 9).

find: найти одну задачу по id

Контекст. Выбираем одну задачу по её id — например, чтобы показать карточку выбранной.

💡 Аналогия. find(). find() — как поиск одной записи по её номеру (id) в списке: назвали номер, нашли нужную и остановились.
const selectedTask = computed(() =>
  tasks().find(t => t.id === selectedId()) ?? null
);
Разбор по шагам: Шаг 1 — find ищет первую задачу с нужным id. Шаг 2 — если не найдено, возвращается undefined. Шаг 3 — ?? null превращает его в явный null, с которым удобнее работать в шаблоне.

find возвращает первый подходящий элемент или undefined. Оператор ?? null нормализует в явный null, если задачи нет.

reduce: посчитать ненулевые приоритеты

Контекст. Считаем, сколько задач с высоким приоритетом — одним проходом по массиву.

💡 Аналогия. reduce(). reduce() — как свёртка чека в одну итоговую сумму: проходим по всем позициям и накапливаем результат в одной переменной.
const highCount = computed(() =>
  tasks().reduce((acc, t) => acc + (t.priority === 'high' ? 1 : 0), 0)
);
Разбор по шагам: Шаг 1 — acc (аккумулятор) начинается с 0. Шаг 2 — для каждой задачи прибавляем 1, если приоритет 'high', иначе 0. Шаг 3 — итог накапливается в acc и становится результатом.

Что увидит пользователь: число задач с высоким приоритетом, обновляющееся само.

reduce сворачивает массив в одно значение. Здесь считаем задачи с приоритетом high.

Когда find, когда filter? find — одна первая запись (например, открытая задача по id). filter — коллекция (список по критерию). reduce — скаляр (сумма, количество). Выбор отражает намерение и результат.
Урок 5 • Раздел 17

17. Сортировка через computed

Сортировка — тоже производное значение. Важно не сортировать исходный массив, а работать с копией.

Контекст. Сортируем задачи по приоритету: high выше всего, low ниже всех.

💡 Аналогия. sort(). Сортировка — как расстановка людей по росту в строю. Менять прямо в «живом строю» опасно, поэтому сначала делаем копию (spread) и сортируем её.
const sortedByPriority = computed(() => {
  const rank = { high: 0, medium: 1, low: 2 };
  return [...tasks()].sort((a, b) => rank[a.priority] - rank[b.priority]);
});
Разбор по шагам: Шаг 1 — создаём «таблицу весов» приоритета. Шаг 2 — [...tasks()] делает копию массива. Шаг 3 — sort сортирует копию по разнице весов. Шаг 4 — возвращаем отсортированную копию, исходный сигнал не тронут.

Что увидит пользователь: список, где сначала high, потом medium, потом low.

Заметьте [...tasks()]: spread создаёт копию, и sort() мутирует копию, а не исходный сигнал. Такой подход безопасен.

sort — мутационный метод. Array.prototype.sort изменяет массив на месте. Поэтому перед сортировкой всегда копируйте: [...arr].sort(...). Иначе вы «мутируете» значение внутри computed, что опасно для неизменяемости.

Контекст. Ещё один пример — сортировка по названию с учётом языка (русского алфавита).

// сортировка по названию (locale-aware)
const sortedByTitle = computed(() =>
  [...tasks()].sort((a, b) => a.title.localeCompare(b.title))
);
Разбор по шагам: Шаг 1 — [...tasks()] копия массива. Шаг 2 — localeCompare сравнивает строки по правилам языка. Шаг 3 — результат — отсортированная копия (исходник не изменён).

Что увидит пользователь: задачи, упорядоченные по алфавиту названий.

Урок 5 • Раздел 18

18. Каскад computed: производное от производного

Computed могут зависеть от других computed. Это позволяет строить цепочки вывода.

const visibleTasks = computed(() => { ... }); // фильтр
const sortedVisible = computed(() => [...visibleTasks()].sort(...));
const visibleCount = computed(() => sortedVisible().length);

Здесь visibleCount зависит от sortedVisible, тот — от visibleTasks, тот — от исходных сигналов. Изменение любого источника «пробегает» по цепочке.

Angular отслеживает эти зависимости и пересчитывает только то, что изменилось.

Держите цепочки короткими и читаемыми. Несколько простых computed читать легче, чем один гигантский. Но не разбивайте на чрезмерное число крошечных фрагментов — это снижает читаемость. Разумный компромисс: один уровень фильтров, один сортировки, один счётчиков.
Урок 5 • Раздел 19

19. Сравнение: методы-геттеры и computed

В уроке 4 мы писали методы-геттеры. Разберём разницу с computed.

КритерийМетод (геттер)computed
Когда вычисляетсяпри каждом вызовелениво, при первом чтении после изменения источников
Кэширует результатнетда
Реактивностьнет (пересчёт вручную)да (следит за источниками)
ЧтениеvisibleTasks()visibleTasks()
// метод-геттер — пересчитывается каждый вызов
visibleTasks() { ... }

// computed — кэшируется и реактивен
readonly visibleTasks = computed(() => ...);

Когда годится метод: если вычисление должно выполняться заново при каждом вызове по иной причине (например, время). Для производных значений от сигналов — computed почти всегда лучше.

Наблюдение. Синтаксис чтения visibleTasks() совпадает — но за ним разная семантика: вызов метода vs чтение сигнала. Помните об этом, читая код.
Урок 5 • Раздел 20

20. Полный цикл TaskFlow с сигналами

Соберём реактивную модель TaskFlow: состояние в сигналах, производные в computed.

Контекст. Один компонент, где есть список, фильтр, производные списки/счётчики и обработчики событий (toggle, create, filter). Это «скелет» всего приложения TaskFlow.

💡 Аналогия. Компонент — как деталь конструктора: он держит свою часть (состояние) и умеет её показывать и менять, но соединяется с другими деталями.
import { Component, computed, signal } from '@angular/core';
import type { Task, TaskFilter } from '../task';

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Изучить сигналы', completed: true },
    { id: 2, title: 'Связать события', completed: false },
  ]);
  readonly filter = signal<TaskFilter>('all');

  readonly visibleTasks = computed(() => {
    const f = this.filter(); // читаем сигнал фильтра → зависимость зафиксирована
    if (f === 'active') return this.tasks().filter(t => !t.completed);
    if (f === 'done') return this.tasks().filter(t => t.completed);
    return this.tasks(); // 'all' — без фильтрации
  });

  // счётчики — производные от списка, пересчитываются сами
  readonly activeCount = computed(() =>
    this.tasks().filter(t => !t.completed).length
  );
  readonly doneCount = computed(() =>
    this.tasks().filter(t => t.completed).length
  );

  onToggled(id: number) {
    // update от текущего массива; map создаёт НОВЫЕ объекты (неизменяемо)
    this.tasks.update(cur =>
      cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  onCreated(title: string) {
    const id = Math.max(0, ...this.tasks().map(t => t.id)) + 1;
    // новый массив со свежей задачей в начале — новая ссылка
    this.tasks.update(cur => [
      { id, title, completed: false },
      ...cur,
    ]);
  }

  onFilterChange(value: TaskFilter) {
    this.filter.set(value);
  }
}
Разбор по шагам: Шаг 1 — состояние в tasks и filter. Шаг 2 — visibleTasks и счётчики считаются из состояния. Шаг 3 — onToggled неизменяемо меняет задачу. Шаг 4 — onCreated добавляет задачу с новым id. Шаг 5 — onFilterChange меняет фильтр через set.

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

Весь «мир» умещается в состояние (сигналы) + производные (computed) + методы-обработчики событий. Шаблон только читает результаты.

✍️ Действие. Выделите реактивную модель в отдельный файл состояния: src/app/state/tasks.store.ts. Пока это обычный класс с сигналами tasks/filter и производными computed; в уроке 6 он станет сервисом через @Injectable, чтобы делить состояние между компонентами.

Контекст. Шаблон компонента: передаём фильтр вниз, перебираем видимые задачи, показываем счётчики.

<app-filter-bar [filter]="filter()" (filterChange)="onFilterChange($event)" />
@for (task of visibleTasks(); track task.id) {
  <app-task-item [task]="task" (toggled)="onToggled(task.id)" />
}
<p>Активных: {{ activeCount() }} · Выполнено: {{ doneCount() }}</p>
Разбор по шагам: Шаг 1 — [filter]="filter()" передаёт текущий фильтр дочернему бару. Шаг 2 — (filterChange) ловит выбор пользователя и зовёт onFilterChange. Шаг 3 — @for рисует видимые задачи. Шаг 4 — счётчики читаются как производные значения.

Что увидит пользователь: рабочий экран со списком, фильтром и живыми счётчиками.

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

21. Связь с React: useState, useMemo, производные

Идея реактивности есть и в React, но синтаксис иной. Проведём параллель (урок 1, раздел 24).

ЗадачаAngularReact
Состояниеsignal()useState()
Обновлениеupdate()/set()setState(prev => ...)
Производное значениеcomputed()useMemo() или просто вычисление при рендере
Побочный эффектeffect()useEffect()
Чтениеtasks() (вызов)tasks (значение)

Контекст. Тот же замысел «производное следует за состоянием», но на React выглядит иначе: состояние в useState, производное — в useMemo с явным списком зависимостей.

💡 Аналогия. Это как два разных будильника одной и той же фирмы: оба будят вас (пересчитывают), но кнопки у них разные. Замысел один — реактивность.
// React (уроки 15–16, для сравнения)
const [tasks, setTasks] = useState([]);
const visibleTasks = useMemo(
  () => tasks.filter(t => t.completed),
  [tasks]
);
Разбор по шагам: Шаг 1 — useState хранит список. Шаг 2 — useMemo пересчитывает visibleTasks, только когда меняется tasks (зависимость в массиве). Шаг 3 — результат используется в шаблоне React.
Важно. Сигналы Angular и state React решают похожую задачу, но устроены по-разному. Сейчас достаточно увидеть параллель; развёрнуто React разберём в уроках 14–16.
Урок 5 • Раздел 22

22. Осторожно: мутации и сигналы

Самый частый источник «немой» ошибки — мутировать объект или массив внутри сигнала и не получить перерисовку.

Контекст. Самый частый источник «немой» ошибки: правим объект «на месте» вместо создания нового. Плохой пример меняет поле напрямую; хороший — создаёт новый объект.

💡 Аналогия. Это как закрасить старый снимок (мутация): сам кадр тот же, система «не видит» изменения. Правильно — сделать новый снимок с нужными данными (новая ссылка).
// ПЛОХО
const task = this.tasks()[0];
task.completed = true; // мутация внутри объекта
// сигнал не «узнал» об изменении — ссылка не поменялась

// ХОРОШО
this.tasks.update(cur =>
  cur.map((t, i) => i === 0 ? { ...t, completed: true } : t)
);
Разбор по шагам (плохой): Шаг 1 — берём ссылку на объект из массива. Шаг 2 — меняем его поле completed напрямую. Шаг 3 — ссылка массива не изменилась → нет перерисовки. Хороший: map создаёт новый массив и новый объект для нужной задачи → сигнал срабатывает.

Ещё одна ловушка — мутировать коллекцию внутри computed:

Контекст. Ещё одна ловушка — мутировать коллекцию внутри computed. Плохой вариант сортирует сам сигнал; хороший — его копию.

// ПЛОХО: sort мутирует массив внутри computed
const bad = computed(() => tasks().sort((a, b) => ...));
// tasks() отдаёт исходный массив, sort его портит

// ХОРОШО: сортируем копию
const good = computed(() => [...tasks()].sort((a, b) => ...));
Разбор по шагам: Шаг 1 (плохой) — tasks() возвращает исходный массив. Шаг 2 — sort меняет его на месте, портя состояние. Хороший: [...tasks()] делает копию, и sort мутирует только её; исходный сигнал цел.

Что увидит пользователь при плохом варианте: список может «сломаться» или перестать обновляться, потому что сам источник испорчен.

Золотое правило. Signal хранит неизменяемые данные. Для любого обновления создавайте новую ссылку (массив/объект). Для любых агрегатов — работайте с копией. Тогда реактивность всегда заметит изменение.

Список опасных мутирующих методов: push, pop, splice, sort, reverse, присваивание полю объекта. Заменяйте их на spread/map/filter.

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

23. Практика 1: сигнал списка задач

Создадим компонент, где список задач — сигнал, и опробуем чтение в шаблоне.

Контекст. Команда Angular CLI создаёт каркас компонента (файлы и папку).

ng generate component task-list
Разбор по шагам: Шаг 1 — CLI создаёт файлы task-list.ts, task-list.html и др. Шаг 2 — регистрирует компонент. Шаг 3 — можно открывать и дописывать логику.

Контекст. В классе компонента объявляем сигнал-список с двумя задачами. Это состояние, которое дальше будем показывать и менять.

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

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  readonly tasks = signal<Task[]>([
    { id: 1, title: 'Изучить сигналы', completed: false },
    { id: 2, title: 'Проверить шаблон', completed: true },
  ]);
}
Разбор по шагам: Шаг 1 — импортируем signal. Шаг 2 — поле tasks инициализируется массивом из двух задач. Шаг 3 — readonly запрещает случайно перезаписать сам сигнал (но не его значение).

Контекст. Шаблон читает сигнал и перебирает задачи через @for, показывая заголовок и галочку/пустое поле по completed.

<!-- task-list.html -->
<h3>Задач в списке: {{ tasks().length }}</h3>
<ul>
  @for (task of tasks(); track task.id) {
    <li>{{ task.title }} — {{ task.completed ? '✅' : '⬜' }}</li>
  }
</ul>
Разбор по шагам: Шаг 1 — {{ tasks().length }} показывает число задач. Шаг 2 — @for перебирает элементы массива. Шаг 3 — для каждой задачи показываем заголовок и статус. Шаг 4 — при изменении сигнала шаблон обновляется сам.

Что увидит пользователь: заголовок «Задач в списке: 2» и два пункта со статусами ✅/⬜.

Шаблон читает tasks(), а внутри @for перебирает элементы. Убедитесь, что в браузере появятся заголовок и два пункта.

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

24. Практика 2: toggle через computed

Свяжем чекбокс с сигналом: переключение меняет состояния, шаблон перерисовывается автоматически.

Контекст. Метод onToggled неизменяемо переключает флаг задачи: создаётся новый массив с новым объектом для нужной задачи.

// task-list.ts
onToggled(id: number) {
  this.tasks.update(cur =>
    cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
  );
}
<!-- task-list.html -->
@for (task of tasks(); track task.id) {
  <li>
    <input
      type="checkbox"
      [checked]="task.completed"
      (change)="onToggled(task.id)"
    />
    {{ task.title }}
  </li>
}
Разбор по шагам: Шаг 1 — [checked] показывает текущее значение completed. Шаг 2 — при клике срабатывает (change). Шаг 3 — onToggled меняет сигнал. Шаг 4 — Angular перерисовывает чекбокс и счётчики.

Что увидит пользователь: отметил чекбокс — галочка появилась/исчезла, без ручного обновления.

Отметьте чекбокс — пункт получит другой checked. Изменение прошло: событие → update() → новый массив → перерисовка.

Проверьте понимание. Попробуйте добавить класс [class.done]="task.completed" и в CSS зачёркивать выполненные. Так вы соедините сигнал и стили (урок 3, разделы про class binding).
Урок 5 • Раздел 25

25. Практика 3: фильтры через computed

Добавим фильтр и visibleTasks в виде computed.

Контекст. Сигнал фильтра + computed, который показывает нужные задачи; метод setFilter меняет фильтр.

import { Component, computed, signal } from '@angular/core';
import type { Task, TaskFilter } from '../task';

// ...
readonly filter = signal<TaskFilter>('all');
readonly visibleTasks = computed(() => {
  const f = this.filter();
  if (f === 'active') return this.tasks().filter(t => !t.completed);
  if (f === 'done') return this.tasks().filter(t => t.completed);
  return this.tasks();
});

setFilter(value: TaskFilter) { this.filter.set(value); }
<button (click)="setFilter('all')">Все</button>
<button (click)="setFilter('active')">Активные</button>
<button (click)="setFilter('done')">Выполненные</button>

@for (task of visibleTasks(); track task.id) {
  <li>{{ task.title }}</li>
}
Разбор по шагам: Шаг 1 — три кнопки вызывают setFilter с разным значением. Шаг 2 — setFilter пишет в сигнал filter. Шаг 3 — visibleTasks() пересчитывается. Шаг 4 — @for рисует отфильтрованный список.

Что увидит пользователь: нажал «Выполненные» — в списке остались только сделанные задачи.

Выберите «Выполненные» — в списке останутся только сделанные. Изменение фильтра автоматически пересчитало visibleTasks().

Урок 5 • Раздел 26

26. Практика 4: счётчики через computed

Добавим счётчики как производные значения.

Контекст. Три счётчика как производные значения — они считаются из списка и обновляются сами.

readonly total = computed(() => this.tasks().length);
readonly activeCount = computed(() =>
  this.tasks().filter(t => !t.completed).length
);
readonly doneCount = computed(() =>
  this.tasks().filter(t => t.completed).length
);
Разбор по шагам: Шаг 1 — total = длина списка. Шаг 2 — activeCount = число с completed === false. Шаг 3 — doneCount = число выполненных. Все пересчитываются сами при изменении списка.

Контекст. Выводим счётчики в шаблоне — они всегда актуальны.

<p>Всего: {{ total() }} · Активных: {{ activeCount() }} · Выполнено: {{ doneCount() }}</p>
Разбор по шагам: Шаг 1 — каждый {{ ...() }} читает свой computed. Шаг 2 — при отметке задачи tasks меняется. Шаг 3 — счётчики пересчитываются и шаблон обновляется.

Что увидит пользователь: помечает задачи чекбоксом — счётчики меняются сами, без ручного пересчёта. Это демонстрация реактивности в действии.

Урок 5 • Раздел 27

27. Практика 5: поиск через find

Реализуем выбор одной задачи по id с помощью computed и find.

Контекст. Сигнал выбранного id и computed, который находит задачу по нему (паттерн из раздела 16, теперь в компоненте).

readonly selectedId = signal<number | null>(null);
readonly selectedTask = computed(() =>
  this.selectedId() === null
    ? null
    : this.tasks().find(t => t.id === this.selectedId()) ?? null
);

select(id: number) { this.selectedId.set(id); }
Разбор по шагам: Шаг 1 — если selectedId пуст (null), возвращаем null. Шаг 2 — иначе find ищет задачу по id. Шаг 3 — ?? null защищает от undefined. Шаг 4 — select меняет выбранный id.
@if (selectedTask(); as task) {
  <div class="detail">
    <h4>{{ task.title }}</h4>
    <p>Приоритет: {{ task.priority }}</p>
  </div>
} @else {
  <p>Выберите задачу</p>
}
Разбор по шагам: Шаг 1 — @if проверяет, что selectedTask() не пуст. Шаг 2 — as task сохраняет результат в переменную. Шаг 3 — показываем карточку. Шаг 4 — иначе показываем подсказку.

Что увидит пользователь: выбрал задачу — открылась её карточка; ничего не выбрано — подсказка.

Новый синтаксис @if (expr; as alias) сохраняет результат в локальную переменную task, чтобы не вызывать selectedTask() многократно.

?? null превращает undefined от некорректного id в явный null — проще проверять и типизировать (урок 2, раздел 37 про null/undefined).
Урок 5 • Раздел 28

28. Практика 6: статистика и каскад computed

Объединим фильтрацию и статистику в каскад computed.

Контекст. Каскад: visibleCount зависит от отфильтрованного списка, а doneShare — от счётчиков. Показываем число видимых и долю выполненных в процентах.

readonly visibleTasks = computed(() => { ... }); // из раздела 25
readonly visibleCount = computed(() => this.visibleTasks().length);
readonly doneShare = computed(() =>
  this.total() === 0
    ? 0
    : Math.round((this.doneCount() / this.total()) * 100)
);
Разбор по шагам: Шаг 1 — visibleCount берёт длину отфильтрованного списка. Шаг 2 — doneShare проверяет деление на ноль. Шаг 3 — считает долю выполненных в процентах. Шаг 4 — всё пересчитывается при изменении источников.

Контекст. Выводим два показателя статистики.

<p>Показано записей: {{ visibleCount() }}</p>
<p>Доля выполненных: {{ doneShare() }}%</p>
Разбор по шагам: Шаг 1 — {{ visibleCount() }} читает первый computed. Шаг 2 — {{ doneShare() }} читает второй. Шаг 3 — при изменении списка/фильтра оба обновляются.

Что увидит пользователь: число видимых задач и процент выполнения, меняющиеся сами.

Здесь visibleCount зависит от visibleTasks, а doneShare — от doneCount и total. Любое изменение источника пробегает по каскаду.

Урок 5 • Раздел 28.1

28.1. Сигналы для отдельных полей: checkbox и форма

Не только списки — отдельные значения (флажок, текст поля) тоже бывают сигналами.

Контекст. Два отдельных сигнала: открыт ли блок, и что введено в поле. Это удобно, когда значение читается в шаблоне или участвует в вычислениях (например, можно ли отправить форму).

const isOpen = signal(false);
const newTitle = signal('');

// обработчик для поля
onInput(event: Event) {
  this.newTitle.set((event.target as HTMLInputElement).value);
}
<input [value]="newTitle()" (input)="onInput($event)" />
<label>
  <input type="checkbox" [checked]="isOpen()" (change)="isOpen.set(!isOpen())" />
  Показать детали
</label>

Отдельные сигналы удобны, когда значение читается в шаблоне или используется в вычислениях. В уроке 4 форма TaskForm использовала обычное поле newTitle; вариант с сигналом даёт ту же реактивность для производных (например, можно ли отправить кнопку).

const canSubmit = computed(() => this.newTitle().trim().length > 0);
<button type="submit" [disabled]="!canSubmit()">Добавить</button>
Разбор по шагам (canSubmit): Шаг 1 — canSubmit читает newTitle(). Шаг 2 — проверяет, что после trim длина больше 0. Шаг 3 — кнопка привязана к !canSubmit(): пустое поле → кнопка выключена.

Что увидит пользователь: кнопка «Добавить» серая и неактивная, пока поле пустое, и становится активной, стоит начать вводить текст — автоматически.

Урок 5 • Раздел 28.2

28.2. Поток данных: снова «данные вниз, события вверх»

Сигналы не отменяют архитектуру урока 4. Родитель владеет сигналом, передаёт производные вниз, а события поднимаются вверх и меняют состояние.

TaskList (владеет tasks, filter)
 ├── FilterBar   ← [filter], (filterChange) → изменяет filter
 ├── StatsBar    ← [tasks]
 └── TaskItem    ← [task], (toggled) → обновляет tasks

Computed помогает родителю передавать необработанные данные или готовые производные:

<app-stats-bar [total]="total()" [done]="doneCount()" />

Можно передать счётчики как входы, а сам StatsBar оставить «чистым» — он просто показывает переданные числа. Так вычисление остаётся у владельца данных.

Выбор. Передавать сырой список [tasks] и считать внутри ребёнка — тоже нормально, если счётчики не нужны больше нигде. Правило: производное хранится там, где его легче переиспользовать, — обычно у владельца источника (урок 4, раздел 7 о lifting state).
Урок 5 • Раздел 28.3

28.3. Схема реактивности: отклик на изменение

Соберём полную картину того, что происходит при клике пользователя.

Путь клика по чекбоксу1. Клик(change) → onToggled(id)событие вверхсобытие2. Обновлениеtasks.update(map({...}))новый массивпроизводные3. Перерисовкаcomputed пересчитанDOM тех узловкоторые читают сигнал
Рисунок 2. Клик → событие → обновление сигнала → пересчёт computed → точечная перерисовка.

Три шага повторяются для любого изменения состояния. Поняв их, вы сможете предсказать поведение любого сигнала в приложении.

Урок 5 • Раздел 28.4

28.4. Эффекты на практике: сохранение и логирование

Разберём два реалистичных применения effect().

Сохранение в localStorage

Контекст. Эффект в конструкторе компонента автоматически сохраняет список задач в браузер при каждом его изменении — без ручных вызовов «сохранить».

💡 Аналогия. localStorage — как шкаф у входа: вы кладёте туда данные, и они переживут даже закрытие вкладки.
import { Component, effect, signal } from '@angular/core';

@Component({ selector: 'app-task-list', standalone: true, ... })
export class TaskList {
  readonly tasks = signal<Task[]>([]);

  constructor() {
    // при каждом изменении списка — сохраняем
    effect(() => {
      localStorage.setItem('tasks', JSON.stringify(this.tasks()));
    });
  }
}
Разбор по шагам: Шаг 1 — эффект читает this.tasks(), значит «подписан» на него. Шаг 2 — при первом запуске сразу сохраняет текущий список. Шаг 3 — при любом изменении списка эффект срабатывает снова.

Что увидит пользователь: сам по себе — ничего на экране, но данные сохраняются «за кадром» и восстановятся после перезагрузки.

Каждый раз, когда tasks меняется, эффект записывает новую версию. Никаких ручных вызовов «сохранить» — изменение состояния само запускает побочное действие.

Логирование изменения фильтра

Контекст. Второй пример — логирование: при смене фильтра пишем в консоль. Это тоже побочное действие «наружу».

readonly filter = signal<TaskFilter>('all');

constructor() {
  effect(() => {
    console.log('Фильтр изменён на:', this.filter());
  });
}
Разбор по шагам: Шаг 1 — эффект читает filter(). Шаг 2 — при создании компонента срабатывает сразу (печатает «all»). Шаг 3 — при смене фильтра печатает новое значение.

Что увидит пользователь: в консоли разработчика — записи о смене фильтра (сам экран не меняется от лога).

Эффект выполняется минимум один раз. При создании компонента effect отрабатывает сразу (главный прогон), затем — при каждом изменении отслеживаемых сигналов. Это нормально.

Эффект удобен, но не перегружайте им компонент. В уроке 6, вынося состояние в сервис, эффекты тоже переносите вместе с логикой.

Урок 5 • Раздел 28.5

28.5. Производная статистика через reduce

Углубим статистику: посчитаем несколько показателей через reduce, чтобы не писать повторяющийся filter.

Контекст. Один проход по массиву через reduce даёт сразу все показатели статистики (вместо нескольких отдельных filter).

💡 Аналогия. reduce. Как свёртка чека в одну итоговую сумму: проходим по всем позициям и накапливаем результат в одной «корзине» данных.
interface TaskStats {
  total: number;
  active: number;
  doneCount: number;
  high: number;
}

const stats = computed<TaskStats>(() =>
  this.tasks().reduce(
    (acc, t) => ({
      total: acc.total + 1,
      active: acc.active + (t.completed ? 0 : 1),
      doneCount: acc.doneCount + (t.completed ? 1 : 0),
      high: acc.high + (t.priority === 'high' ? 1 : 0),
    }),
    { total: 0, active: 0, doneCount: 0, high: 0 }
  )
);
Разбор по шагам: Шаг 1 — для каждой задачи обновляем аккумулятор acc. Шаг 2 — total растёт на 1 всегда; active/doneCount — в зависимости от completed; high — если приоритет высокий. Шаг 3 — результат — готовый объект статистики.

В шаблоне:

<p>Всего: {{ stats().total }}</p>
<p>Активных: {{ stats().active }} · Выполнено: {{ stats().doneCount }}</p>
<p>Высокий приоритет: {{ stats().high }}</p>

Объект-аккумулятор делает статистику компактной. Если показателей много, reduce эффективнее нескольких filter.

Типизируйте результат. computed<TaskStats>(...) явно заявляет форму производного значения. Это улучшает читаемость и ловит ошибки на этапе компиляции (урок 2, разделы 29–30).
Урок 5 • Раздел 28.6

28.6. Чтение старого кода: subscription и BehaviorSubject

В существующих Angular-проектах, созданных до сигналов, состояние часто хранилось в RxJS. Встретив такое, вы должны уметь читать, но не писать новый код так.

// RxJS / BehaviorSubject (старый подход)
import { BehaviorSubject } from 'rxjs';

export class TaskService {
  private readonly tasks$ = new BehaviorSubject<Task[]>([]);
  readonly tasks = this.tasks$.asObservable();
}
// компонент подписывается (старый подход)
this.tasks$.subscribe(tasks => {
  this.tasks = tasks;
});

Параллель с сигналами:

RxJS (старое)Сигнал (новое)
BehaviorSubject<T>(init)signal<T>(init)
.next(value).set(value) / .update(fn)
.asObservable().asReadonly()
.subscribe(fn)effect() / чтение в шаблоне
map/filter в pipescomputed()
Зачем знать RxJS. Многие реальные проекты Angular всё ещё на нём. Умение читать такие подписки и переводить их в сигналы — важный навык поддержки. Наш курс пишет на сигналах; RxJS появится ещё в HttpClient (урок 7), где он всё ещё уместен для потока запросов.
Урок 5 • Раздел 28.7

28.7. Что такое контекст инъекций и почему эффекты в компоненте

Вы могли заметить, что вызовы computed() и effect() выполняются в теле компонента (в поле или в конструкторе), а не внутри произвольной функции. Это связано с контекстом инъекций.

Angular создаёт эффект в «реактивном контексте» текущего компонента, чтобы связать его с жизненным циклом этого компонента. Когда компонент уничтожается, его эффекты тоже автоматически прекращаются — не нужно вручную отписываться.

// эффект связан с жизненным циклом компонента
export class TaskList {
  constructor() {
    effect(() => { /* живёт, пока жив компонент */ });
  }
}

Если вызвать effect() вне такого контекста, Angular выдаст ошибку «effect() can only be used within an injection context». Это частая неожиданность.

Решение. Объявляйте сигналы, computed и effect прямо в классе компонента или сервиса (урок 6 покажет сервисы, где контекст инъекций тоже есть). Не выносите их в обычные свободные функции без явной передачи контекста.
Урок 5 • Раздел 28.8

28.8. Сравнение сигналов в Angular и React

Закрепим таблицу сравнения из раздела 21 на примере одного сценария — отфильтровать активные задачи.

// Angular — сигналы
const tasks = signal<Task[]>([]);
const filter = signal('all');
const visible = computed(() => filter() === 'active'
  ? tasks().filter(t => !t.completed)
  : tasks());

// React — state + мемоизация
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
const visible = useMemo(() => filter === 'active'
  ? tasks.filter(t => !t.completed)
  : tasks, [tasks, filter]);

Механизмы различны, намерение то же: производное значение, следующее за состоянием.

АспектAngular signalReact state
Чтениевызов ()прямое значение
Обновление от предыдущегоupdate(fn)setState(prev => ...)
Пересчёт производногокомпозируется автоматическипересборка при каждом рендере + useMemo
Производительность перерисовкиточечная (зависимости)пересборка компонента
Не «лучше/хуже». Это разные модели, каждая отвечает своей экосистеме. Задача курса — чтобы вы одинаково уверенно применяли их в TaskFlow. Детали React — в уроках 14–16.
Урок 5 • Раздел 28.9

28.9. Computed от нескольких сигналов

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

const tasks = signal<Task[]>([]);
const filter = signal<TaskFilter>('all');
const search = signal('');

const result = computed(() => {
  const q = search().toLowerCase();
  return this.filter()
    .split('')
    .map(() => 0) // placeholder ради примера
    .length ? tasks() : tasks();
});

Лучше покажем реалистичный пример — фильтр плюс поиск:

const visibleTasks = computed(() => {
  const f = filter();
  let list = tasks();
  if (f === 'active') list = list.filter(t => !t.completed);
  if (f === 'done') list = list.filter(t => t.completed);
  const q = search().trim().toLowerCase();
  if (q) list = list.filter(t => t.title.toLowerCase().includes(q));
  return list;
});

Здесь computed отслеживает три сигнала: tasks, filter, search. Изменение любого из них пересчитывает результат.

Гранулярность. Разные сигналы — изолированные источники. Angular знает, что именно изменилось, и не пересчитывает без нужды. Это основа точной реактивности.
Урок 5 • Раздел 28.10

28.10. Модель Task: readonly id и неизменяемые поля

Неизменяемость начинается с типа. Объявим модель так, чтобы компилятор помогал её соблюдать.

export type Priority = 'high' | 'medium' | 'low';

export interface Task {
  readonly id: number;        // нельзя перезаписать
  title: string;
  completed: boolean;
  priority: Priority;
}

Поле readonly id защищает идентификатор от случайной перезаписи. Другие поля остаются изменяемыми по типу, но мы всё равно меняем их через spread (создавая новый объект).

// правильно: новый объект с новым id невозможно (readonly), но меняем title
this.tasks.update(cur => cur.map(t => t.id === id ? { ...t, title: newTitle } : t));

Параллель: в уроке 2 (раздел 28) мы видели readonly для массивов и объектов TypeScript. Здесь тот же инструмент применяется к полю модели.

Тип помогает реактивности. Хорошо настроенная модель не мешает неизменяемым обновлениям и противодействует случайным мутациям на уровне типов. В уроке 6 модель станет официальным контрактом сервиса.
Урок 5 • Раздел 28.11

28.11. Передача сигнала в дочерний компонент

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

// task-list.ts
<app-stats-bar [tasksSignal]="tasks" />
// stats-bar.ts
import { Component, input } from '@angular/core';
import type { Signal } from '@angular/core';
import type { Task } from '../task';

export class StatsBar {
  readonly tasks = input.required<Signal<Task[]>>();
}

Дочерний читает внутри computed или метода:

readonly total = computed(() => this.tasks().().length);

Синтаксис this.tasks()() может запутать: первый вызов берёт сам сигнал, второй читает его значение. Чаще проще передавать значение [tasks]="tasks()", чтобы дочерний видел обычный массив.

Когда передавать сам сигнал, а когда значение? Если дочерний должен подписаться на изменения напрямую — сигнал. Если он просто получает срез (обычный массив или число) — значение. Для простоты и чистоты предпочитайте computed() в родителе и передачу готовых величин.
Урок 5 • Раздел 28.12

28.12. Структура сигнала: примитив, массив, объект

Что можно хранить в сигнале? Все что угодно: примитив, массив, объект, даже другой сигнал (хотя это редкость). Структура влияет на то, как вы его обновляете.

Тип значенияОбновлениеПример
Примитив (string/number/boolean)set(newValue)filter.set('done')
Массивновая ссылка через spread/map/filterupdate(a => [...a, task])
Объектновый объект через spreadupdate(o => ({ ...o, completed: true }))

Объект в сигнале — например, целое состояние формы:

const form = signal({ title: '', priority: 'low', completed: false });

// обновить одно поле — новый объект (поле задачи — completed, не done)
form.update(f => ({ ...f, priority: 'high' }));

Хранение целого объекта удобно, когда поля обновляются вместе. Для независимых полей чаще держат отдельные сигналы (урок 28.1).

Не вкладывайте глубоко. Слишком глубокие вложенные объекты в одном сигнале требуют много spread на каждом уровне. Если структура сложная, разбивайте на несколько сигналов или выносите в сервисы (урок 6).
Урок 5 • Раздел 28.13

28.13. Вынос чистой логики в функции

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

Контекст. Чистую логику (фильтрация, подсчёт, сортировка) выносим в обычные функции-утилиты, чтобы переиспользовать и тестировать их без Angular.

💡 Аналогия. Утилиты — как отдельные кухонные приборы: миксер, нож... каждый делает одну операцию чисто и понятно, а в компоненте вы просто «включаете» нужный.
// tasks.util.ts
import type { Task, TaskFilter } from './task';

export function filterTasks(tasks: Task[], f: TaskFilter): Task[] {
  if (f === 'active') return tasks.filter(t => !t.completed);
  if (f === 'done') return tasks.filter(t => t.completed);
  return tasks;
}

export function countDone(tasks: Task[]): number {
  return tasks.filter(t => t.completed).length;
}

export function sortByPriority(tasks: Task[]): Task[] {
  const rank = { high: 0, medium: 1, low: 2 };
  return [...tasks].sort((a, b) => rank[a.priority] - rank[b.priority]);
}
Разбор по шагам: Шаг 1 — filterTasks возвращает отобранный массив. Шаг 2 — countDone считает выполненные. Шаг 3 — sortByPriority сортирует копию. Все функции чистые: на тех же входах — тот же результат.

В компоненте computed просто вызывает утилиты:

readonly visibleTasks = computed(() =>
  filterTasks(this.tasks(), this.filter())
);
Разбор по шагам: Шаг 1 — computed читает tasks() и filter(). Шаг 2 — передаёт их в filterTasks. Шаг 3 — результат становится значением сигнала. Шаг 4 — пересчёт происходит автоматически при изменении любого источника.

Такой слой делает computed лаконичным, а логику — переносимой и тестируемой отдельно от Angular.

Разделение ответственности. Компонент отвечает за жизненный цикл и реактивность; утилиты — за чистое преобразование данных. Это упрощает и тесты (урок 12), и переиспользование. То же правило встретится в React (уроки 14–16).
Урок 5 • Раздел 28.14

28.14. Автодополнение и prefill через computed

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

const lastCreated = signal<Task | null>(null);

const suggestion = computed(() =>
  this.lastCreated() ? 'Продолжите: ' + this.lastCreated()!.title : ''
);

Non-null assertion ! работает, потому что мы уже проверили в условии. В шаблоне:

@if (suggestion()) {
  <p class="hint">{{ suggestion() }}</p>
}

Ещё пример — заголовок по умолчанию для новой задачи на основе выбранного фильтра:

const defaultTitle = computed(() =>
  this.filter() === 'done' ? 'Новая задача (завершена)' : 'Новая задача'
);
Computed не обязателен только для чисел и списков. Он делает любые выводы реактивными: подсказки, подписи, видимость, атрибуты. Главное — выражать зависимость от состояния, а не хранить копию.
Урок 5 • Раздел 28.15

28.15. Синхронизация фильтра и видимости

Фильтр и видимый список связаны: изменение одного автоматически обновляет другое. Это и есть главное преимущество реактивности.

// состояние
readonly filter = signal<TaskFilter>('all');

// производная
readonly visibleTasks = computed(() => filterTasks(this.tasks(), this.filter()));

// событие от FilterBar
onFilterChange(value: TaskFilter) {
  this.filter.set(value);
}

Никакой ручной «синхронизации»: вы меняете фильтр, а видимый список пересчитывается сам. Строка кода, которую пишет разработчик, — только filter.set(value).

<app-filter-bar [filter]="filter()" (filterChange)="onFilterChange($event)" />
@for (task of visibleTasks(); track task.id) { ... }
Ментальная модель. Думайте так: состояние — это правда, а всё остальное — её отражение. Вы пишете правду один раз, а отражения (списки, счётчики, подписи) появляются сами. Это и есть декларативный UI из урока 1 (раздел 12).
Урок 5 • Раздел 28.16

28.16. Поиск по строке: ещё один computed

Добавим поле поиска и связанный с ним computed, как в разделе 28.9.

readonly query = signal('');

readonly visibleTasks = computed(() => {
  let list = filterTasks(this.tasks(), this.filter());
  const q = this.query().trim().toLowerCase();
  if (q) {
    list = list.filter(t => t.title.toLowerCase().includes(q));
  }
  return list;
});

onSearch(event: Event) {
  this.query.set((event.target as HTMLInputElement).value);
}
<input #q (input)="onSearch($event)" placeholder="Поиск по названию" />

Композиция двух производных (фильтр + поиск) в одном computed даёт комбинированный результат. При работе с большими списками такое сочетание фильтрует сразу по двум критериям.

Ученикам. Задача 5 в самостоятельной работе связана с поиском; этот раздел даёт готовый паттерн. Найдите и примените подсказку.
Урок 5 • Раздел 28.17

28.17. Когда пора переходить на сервисы

Пока реактивное состояние живёт в одном компоненте, сигналы и computed в нём уместны. Но есть два признака, что пора выносить в сервис (урок 6).

// сейчас: сигнал внутри компонента
export class TaskList {
  readonly tasks = signal<Task[]>([]);
}

// урок 6: сигнал в сервисе, компонент читает его через DI
@Injectable({ providedIn: 'root' })
export class TaskService {
  readonly tasks = signal<Task[]>([]);
}
Принцип. Не выносите всё в сервисы заранее — это избыточно. Выносите тогда, когда появилась реальная потребность: общий доступ или дублирование логики. Для текущего TaskFlow состояние в одном компоненте корректно.
Урок 5 • Раздел 28.18

28.18. Проверка реактивности: маленькие тесты

Сигналы и computed легко проверить вне браузера — они обычные классы. Это упрощает тестирование (урок 12).

// task.logic.spec.ts
import { computed, signal } from '@angular/core';

it('счётчик следует за списком', () => {
  const tasks = signal([{ completed: false }, { completed: true }]);
  const done = computed(() => tasks().filter(t => t.completed).length);

  expect(done()).toBe(1);
  tasks.set([{ completed: true }, { completed: true }]);
  expect(done()).toBe(2); // пересчиталось само
});

Плюс вынесенных утилит (раздел 28.13) очевиден: чистые функции тестируются всего строкой импорта, без монтирования компонента.

Польза для дисциплины. Если логика отделена от компонента, тест «чистый» и быстрый. Чем больше вычислений — в функциях, тем меньше приходится покрывать компонент тестами. Это снижает стоимость сопровождения.
Урок 5 • Раздел 28.19

28.19. Производительность больших списков

Сигналы комфортны на обычных объёмах. Но если список огромный, важно понимать, что вызывает пересчёт.

// каждый computed фильтрует весь массив заново
const highCount = computed(() =>
  this.tasks().filter(t => t.priority === 'high').length
);

Фильтрация массива — O(n). На тысячах задач это мгновенно; на сотнях тысяч — ощутимо. Приёмы оптимизации:

Не оптимизируйте заранее. Для учебного TaskFlow достаточно корректной логики. Производительность оптимизируют по факту измерений. Главное — не писать мутаций и не вызывать тяжёлые вычисления в шаблоне без нужды.
Урок 5 • Раздел 28.20

28.20. Обратная связь: события изменяют сигнал

Полный цикл реактивности замыкается: события дочерних компонентов поднимаются наверх и меняют сигнал, а computed пересчитывает производные.

readonly tasks = signal<Task[]>([]);
readonly visibleTasks = computed(() => filterTasks(this.tasks(), this.filter()));

onToggled(id: number) {
  this.tasks.update(cur => cur.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
}
<app-task-item
  [task]="task"
  (toggled)="onToggled(task.id)"
/>

Клик в дочернем → событие вверх → update() → сигнал изменён → computed пересчитан → шаблон обновлён. Один цикл, четыре шага, уже знакомых из раздела 28.3.

Здесь сигнал выступает единой точкой правды: дочерний не трогает данные напрямую, только сообщает намерение. Это согласуется с уроками 4–5.

Запомните связку. События меняют сигналы; сигналы питают computed; computed кормят шаблон. Ни дочерние, ни шаблон не хранят состояния — они читают и сообщают.
Урок 5 • Раздел 28.21

28.21. @let и локальные переменные из computed

Если computed-результат используется в шаблоне несколько раз, можно сохранить его в локальную переменную через @let (новый синтаксис Angular).

@let visible = visibleTasks();

@for (task of visible; track task.id) {
  <app-task-item [task]="task" />
}
<p>Показано: {{ visible.length }}</p>

@let берёт значение один раз и переиспользует, не вызывая computed повторно на каждой строке. Это и читаемость, и небольшой выигрыш в производительности.

То же в форме с условным блоком:

@if (selectedTask(); as task) {
  ...
}

; as task внутри @if даёт псевдоним непустого значения. Обе конструкции — способ «не вызывать сигнал по несколько раз».

Когда применять. Если вызвали visibleTasks() два и более раз в одном фрагменте — используйте @let. Если один раз и рядом по коду — можно и напрямую. Баланс читаемости и краткости.
Урок 5 • Раздел 28.22

28.22. Опциональные фильтры и значения по умолчанию

Некоторые сигналы могут быть «пустыми» или иметь значение по умолчанию. Поработаем с этим аккуратно.

const selectedId = signal<number | null>(null);
const query = signal('');

// опциональный фильтр: если query пуст — не фильтруем
const visibleTasks = computed(() => {
  let list = tasks();
  const q = query().trim().toLowerCase();
  if (q) list = list.filter(t => t.title.toLowerCase().includes(q));
  return list;
});

Проверка if (q) — guard-приём из урока 2 (раздел 6): обрабатываем пустое значение раньше и выходим.

Для «нет выбора» используем null:

const selectedTask = computed(() =>
  selectedId() === null ? null : tasks().find(t => t.id === selectedId()) ?? null
);
null vs undefined vs ''. null — явно «нет значения»; undefined — «не присвоено»; пустая строка '' — «пустой ввод». Для сигналов выбирайте смысл заранее: опциональный выбор — null, строковый поиск — ''.
Урок 5 • Раздел 28.23

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

Финальное упражнение соединяет все приёмы: сигналы состояния, каскад computed и неизменяемые обновления.

readonly tasks = signal<Task[]>([...]);
readonly filter = signal<TaskFilter>('all');
readonly query = signal('');
readonly sortBy = signal<'priority' | 'title'>('priority');

readonly visibleTasks = computed(() => {
  let list = filterTasks(this.tasks(), this.filter());

  const q = this.query().trim().toLowerCase();
  if (q) list = list.filter(t => t.title.toLowerCase().includes(q));

  if (this.sortBy() === 'priority') return sortByPriority(list);
  return [...list].sort((a, b) => a.title.localeCompare(b.title));
});

readonly shownCount = computed(() => this.visibleTasks().length);
<input (input)="onSearch($event)" placeholder="Поиск" />
<button (click)="onFilter('all')">Все</button>
...
@let visible = visibleTasks();
@for (task of visible; track task.id) { ... }
<p>Показано: {{ shownCount() }}</p>

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

Проверка. Убедитесь, что задача из задания 5 самостоятельной работы (сортировка) и задание по поиску работают вместе. Если да — вы освоили главный инструмент реактивного состояния.
Урок 5 • Раздел 28.24

28.24. TaskForm на сигналах: полный сценарий

Соединим форму и список через сигналы в полный цикл: ввод → событие → обновление сигнала списка → пересчёт производных.

Контекст. Полный сценарий формы на сигналах: поле ввода — сигнал, кнопка блокируется через computed, при отправке форма «излучает» событие родителю.

// task-form.ts — поле как сигнал
import { Component, computed, output, signal } from '@angular/core';

export class TaskForm {
  readonly newTitle = signal('');
  readonly created = output<string>();

  readonly canSubmit = computed(() => this.newTitle().trim().length > 0);

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

  submit() {
    const title = this.newTitle().trim();
    if (!title) return;                  // guard clause
    this.created.emit(title);
    this.newTitle.set('');               // очистка поля
  }
}
Разбор по шагам: Шаг 1 — newTitle хранит текст поля. Шаг 2 — canSubmit разрешает отправку, только если есть текст. Шаг 3 — onInput пишет ввод в сигнал. Шаг 4 — submit проверяет guard, излучает событие created и очищает поле.

Что увидит пользователь: пока поле пустое — кнопка неактивна; ввёл текст — кнопка активна; отправил — поле очистилось, кнопка снова неактивна.

<!-- task-form.html -->
<input [value]="newTitle()" (input)="onInput($event)" aria-label="Название задачи" />
<button type="submit" [disabled]="!canSubmit()">Добавить</button>
<!-- родитель: слушает событие и меняет сигнал -->
<app-task-form (created)="onCreated($event)" />
// task-list.ts
onCreated(title: string) {
  const id = Math.max(0, ...this.tasks().map(t => t.id)) + 1;
  this.tasks.update(cur => [{ id, title, completed: false }, ...cur]);
}
Разбор по шагам: Шаг 1 — родитель ловит событие created с заголовком. Шаг 2 — вычисляет новый уникальный id. Шаг 3 — update создаёт НОВЫЙ массив с задачей в начале (новая ссылка). Шаг 4 — сигнал срабатывает, список и счётчики обновляются.

Что увидит пользователь: новая задача появляется вверху списка, счётчики пересчитываются автоматически.

Кнопка автоматически активна/неактивна в зависимости от поля — через canSubmit(). Очистка поля после отправки делает [disabled] снова истинным. Всё реактивно, без ручного контроля.

Сравнение с уроком 4. В уроке 4 TaskForm использовал обычное поле newTitle = ''. Здесь тот же результат достигается сигналом, что открывает путь к произведным вроде canSubmit. Обе реализации допустимы; сигнал добавляет реактивность формам.
Урок 5 • Раздел 28.25

28.25. Архитектурный итог: реактивное состояние как система

Соберём всё, что умеет делать реактивное состояние TaskFlow к концу урока.

ЗадачаКак реализовано
Хранение спискаsignal<Task[]>([])
Выбранный фильтрsignal<TaskFilter>('all')
Строка поискаsignal('')
Отфильтрованный списокcomputed(() => ...)
Счётчикиcomputed(() => filter(...).length)
Выбранная задачаcomputed(() => find(...))
Сохранение измененияeffect(() => localStorage...)
Обработка событийметоды обновляют сигналы (set/update)

Итоговая картина — единый «источник правды» (сигналы) и её реактивные «отражения» (computed). Компоненты лишь читают отражения и сообщают о намерениях.

Состояние (сигналы)
   │  читают
   ▼
Производные (computed)
   │  кормят
   ▼
Шаблон (декларативно показывает)
   ▲
   │  событийный вызов
Методы-обработчики  →  обновляют сигналы
Основа для уроков 6–7. В уроке 6 эти сигналы переедут в сервис, в уроке 7 — получат данные из HTTP. Ментальная модель не изменится: источник → производные → шаблон. Вы уже понимаете ядро реактивности Angular.
Урок 5 • Раздел 29

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

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

Задание 1 — Счётчик выполненных

Создайте computed(), который возвращает количество выполненных задач, и выведите в шаблон.

Задание 2 — Переключение фильтра

Добавьте три кнопки фильтра и visibleTasks через computed. Проверьте переключение.

Задание 3 — Процент выполнения

Выведите процент выполненных задач через computed, как строку «42%».

Задание 4 — Поиск задачи по id

Реализуйте selectedTask через find и отобразите карточку выбранной задачи.

Задание 5 — Сортировка по приоритету

Добавьте sortedTasks, копирующий и сортирующий список по приоритету.

Задание 6 — Эффект сохранения

Добавьте effect(), который при изменении списка пишет JSON в localStorage.

Задание 7 — Кнопка формы

Сделайте кнопку «Добавить» неактивной, пока поле названия пустое, через computed.

Урок 5 • Раздел 30

30. Подсказки к самостоятельной работе

Совет: сначала сформулируйте идею, затем открывайте код.

Задание 1. readonly doneCount = computed(() => this.tasks().filter(t => t.completed).length);, вывод {{ doneCount() }}.
Задание 2. Внутри computed используйте if (this.filter() === 'active') ..., не путайте сигнал filter и метод filter() массива.
Задание 3. Math.round((doneCount() / total()) * 100) с проверкой total() === 0, чтобы не делить на ноль.
Задание 4. this.tasks().find(t => t.id === this.selectedId()) ?? null и @if (selectedTask(); as task).
Задание 5. [...this.tasks()].sort((a,b) => rank[a.priority] - rank[b.priority]) — копия перед sort.
Задание 6. В конструкторе: effect(() => localStorage.setItem('tasks', JSON.stringify(this.tasks()))).
Задание 7. canSubmit = computed(() => this.newTitle().trim().length > 0), [disabled]="!canSubmit()".
Урок 5 • Раздел 31

31. Частые ошибки реактивности

Разберём типичные проблемы, которые возникают при работе с сигналами.

СимптомПричинаРешение
Список не обновляется после setМутация исходного массива/объектаВсегда новая ссылка: spread/map/filter
{{ tasks }}Забыты скобки вызова сигнала{{ tasks() }}
effect() вне контекстаВызов в свободной функцииВызывать в компоненте/сервисе
computed пересчитывается при каждом чтенииВнутри побочный вызов/нестабильностьДержать computed «чистым» от побочных эффектов
Бесконечный циклЭффект пишет в сигнал, который читаетРазорвать зависимость; эффекты — наружу
sort испортил данныеsort мутирует массив из сигналаСортировать копию [...arr]
Главный источник «немых» багов — мутация. Если сигнал хранит массив, никогда не меняйте его push/splice/sort и не присваивайте поля объектам внутри. Создавайте новые ссылки.
Урок 5 • Раздел 32

32. Резюме: как устроено реактивное состояние

Пять правил реактивности в Angular.

Пять правил реактивного состояния1. signal — состояниечитай через ()2. set / updateupdate — от текущего3. computed — выводлениво + кэш + реактивно4. effect — побочности5. неизменяемостьновая ссылка, а не мутация
Рисунок 3. Пять правил, которые стоит заучить для реактивного состояния.

Когда эти правила становятся привычными, TaskFlow на сигналах пишется и читается легко: состояние — в сигналах, вывод — в computed, а шаблон лишь отражает результат. Любая перестройка данных, к которой вы придёте в уроках 6–12 (сервисы, HTTP, формы, маршрутизация), сохранит эту фундаментальную схему.

Урок 5 • Раздел 33

33. Контрольные вопросы

Ответьте сначала своими словами, затем сверьтесь с конспектом. Если не можете объяснить — перечитайте соответствующий раздел.

  1. Чем состояние отличается от производного значения?
  2. Как создать, прочитать и изменить signal()?
  3. Когда нужен set(), а когда update()?
  4. Что делает computed(), и почему он «ленивый»?
  5. Зачем asReadonly()?
  6. В чём смысл «неизменяемого обновления» и почему оно критично для сигналов?
  7. Почему перед sort() нужна копия массива?
  8. Что такое каскад computed, и как он работает?
  9. Когда уместен effect(), а когда нет?
  10. В чём разница между методом-геттером и computed?
  11. Как сигналы связаны с перерисовкой DOM?
  12. Как эта модель переносится на React (useState/useMemo)?
  13. Что такое каскад computed и как отслеживаются зависимости?
  14. Почему внутри computed не следует выполнять побочные действия?
  15. Как @let помогает шаблону при многократном чтении computed?
  16. Когда невыгодно держать глубоко вложенный объект в одном сигнале?
Урок 5 • Раздел 34

34. Мини-словарь

ТерминОпределение
Состояние (state)Данные, которые хранятся и могут меняться.
Производное значениеДанные, вычисляемые из состояния (не хранятся).
signal()Реактивный контейнер значения.
set() / update()Способы записать/вычислить новое значение сигнала.
computed()Реактивное производное значение с кэшированием.
effect()Побочное действие при изменении прочитанных сигналов.
РеактивностьСпособность значения автоматически следовать за источниками.
Неизменяемое обновлениеСоздание новой ссылки вместо мутации.
Spread { ... }Оператор копирования полей объекта/элементов массива.
КэшированиеПовторное использование ранее вычисленного результата.
readonly-сигналСигнал, доступный только для чтения (asReadonly).
Контекст инъекцийОбласть выполнения, где Angular разрешает effect()/computed().
@letЛокальная переменная в шаблоне из выражения (например, из computed).
asReadonly()Метод сигнала, отдающий доступное только для чтения представление.
Derived / производноеЗначение, вычисляемое из состояния, а не хранимое отдельно.
Source of truthЕдинственный авторитетный источник значения в приложении.
Урок 5 • Раздел 35

35. Источники и продолжение

Для самостоятельного углубления:

Следующий шаг. В уроке 6 мы вынесем сигналы и computed из компонентов в сервисы с помощью Dependency Injection. Это позволит делить реактивное состояние между разными компонентами и подключать модель Task официально через DI.
Урок 5 • Раздел 36

36. Чек-лист урока

Готовность к уроку 6. Если вы можете выполнить все задания раздела 29 и объяснить, что меняется при клике по чекбоксу, — реактивность освоена. Осталось вынести её в сервисы, и это будет тема следующего занятия.

🛠 Практика к уроку 5

Замените ручные пересчёты на сигналы и computed.

Задание 1. Состояние и производные значения

const tasks = signal<Task[]>([ /* ... */ ]);
const doneCount = computed(() =>
  tasks().filter(t => t.completed).length);
tasks.update(list => [...list, newTask]); // счётчики пересчитались сами

Задание 2. Фильтрация через computed

const visibleTasks = computed(() => {
  const f = filter();
  if (f === 'done')   return tasks().filter(t => t.completed);
  if (f === 'active') return tasks().filter(t => !t.completed);
  return tasks();
});
Полная версия. Практика: Урок 5 → — сигналы, computed, effect и песочница.
Куда дальше: тренируй сигналы в практике к уроку 5, а про сервисы и DI — в Уроке 6.

📖 Глоссарий

ТерминЖизненная аналогияКоротко
signalгруппа в мессенджереконтейнер со значением; при изменении уведомляет всех подписчиков
computedитоговый счёт в игрепроизводное значение, пересчитывается само из сигналов-источников
effectбудильникреагирует на изменение сигнала; место для побочных действий (сохранение, логи)
состояние (state)статус заказаданные, которые хранятся и меняются со временем (список задач, фильтр)
неизменяемостьновый снимок вместо правки оригиналаобновляем через map/filter/spread, чтобы сигнал заметил изменение
filterситечко для чаяпропускает в новый массив только подходящие элементы
mapконвейерпреобразует каждый элемент, создавая новый массив
findпоиск по номерувозвращает первый подходящий элемент или undefined

🧠 Самопроверка

1. Чтобы обновить значение computed(), нужно каждый раз вызывать его вручную при изменении данных. Верно или неверно?

2. В шаблоне Angular можно менять значение сигнала напрямую через set(). Верно или неверно?

3. Если внутри сигнала-массива поменять один элемент «на месте», сигнал автоматически заметит изменение. Верно или неверно?

Прогресс курса