computed(). Урок 4 показал, как компоненты общаются; урок 5 — как Angular автоматически пересчитывает производные значения и перерисовывает только нужное.Студенческий материал. Его можно использовать как самостоятельный конспект, раздаточный материал и справочник после занятия.
В приложении есть два вида информации: состояние (то, что хранится) и производные значения (то, что из него вычисляется). Смешение этих двух видов — источник огромного количества ошибок.
Состояние 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 заменит ручной пересчёт на автоматический реактивный механизм.
К этому моменту у вас есть база: signal() для состояния (урок 1, 4), методы для вычисления (урок 4) и неизменяемые обновления через map/filter/spread (урок 2). Урок 5 систематизирует реактивность.
| Конструкция | Что делали раньше | Что добавим в уроке 5 |
|---|---|---|
signal() | хранение списка и фильтра | типы, readonly, set/update |
Геттер visibleTasks() | ручной пересчёт при вызове | computed() — автоматический пересчёт |
Метод total() | вычисление на каждый запрос | computed() со кэшированием |
| Побочные действия | — | effect() для side effects |
| Перерисовка | «сигнал изменился — шаблон поменялся» | почему и когда Angular перерисовывает |
Сигнал — это контейнер, который хранит значение и уведомляет о его изменении. Создаётся через 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
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[]>([]);
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]);
}
}
tasks инициализируется одной задачей. Шаг 2 — при вызове addOne Angular берёт текущий массив cur. Шаг 3 — через spread [...cur, newTask] создаётся новый массив (новая ссылка). Шаг 4 — сигнал «видит» новую ссылку и уведомляет подписчиков.Что увидит пользователь: в списке появится новая задача без ручного обновления.
this.tasks(). В шаблоне тоже пишете tasks() со скобками. Это главное отличие от обычного поля: сигнал — это функция, которая возвращает текущее значение.set эффект (подписчик) пересчитывает отображение. Angular-версия — через ng serve.У любому записываемому сигналу есть два метода изменения значения.
Контекст. Оба метода меняют значение сигнала, но по-разному: set() заменяет целиком, update() строит новое на основе старого. Это пригодится, когда в TaskFlow мы будем переключать фильтр или менять задачу.
set() — как сказать «поставьте термостат на 22 градуса» (новое значение известно). update() — как сказать «сделай на 1 градус теплее», потому что надо знать текущее.// set() — присвоить целиком
this.filter.set('done');
// update() — вычислить новое на основе текущего
this.filter.update(current => current === 'all' ? 'done' : 'all');
this.filter.set('done') сразу записывает значение 'done' (оно не зависит от старого). Шаг 2 — update получает текущее значение в переменную current. Шаг 3 — если было 'all', возвращаем 'done', иначе 'all'. Шаг 4 — сигнал уведомляет подписчиков.Что увидит пользователь: список задач перестроится под выбранный фильтр.
set() удобен, когда новое значение не зависит от старого — например, выбранный фильтр. update() нужен, когда новое значение получается из текущего: переключение флажка, добавление в массив, изменение одного поля.
| Случай | Подходит | Пример |
|---|---|---|
| Присвоить новый фильтр | set() | filter.set('active') |
| Переключить completed | update() | update(t => ({ ...t, completed: !t.completed })) |
| Добавить в массив | update() | update(a => [...a, task]) |
| Удалить из массива | update() | update(a => a.filter(x => x.id !== id)) |
update(). Если нет — set(). update() перестраховывает от устаревшего значения, поэтому для массивов и объектов чаще используют его.Не каждый, кто читает сигнал, должен его менять. 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.
Метод computed() создаёт сигнал, значение которого вычисляется из других сигналов. Когда источники меняются, производное значение пересчитывается автоматически.
Контекст. Самый простой пример: удвоенный счётчик. Когда меняется count, doubled пересчитывается сам.
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 — автоматически
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() всегда отражает текущее состояние списка
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();
});
f. Шаг 2 — если 'active', оставляем задачи, где completed ложно. Шаг 3 — если 'done', оставляем выполненные. Шаг 4 — иначе возвращаем весь список. Результат пересчитывается сам при смене фильтра или списка.computed() читается как сигнал (visibleTasks()) и не имеет set() — вы не назначаете производное значение вручную, оно вычисляется.computed в виде функции, которая пересчитывает производное значение при каждом изменении источника. Angular-версия — через ng serve.computed считает активные задачи: кнопка «Добавить задачу» меняет список, а итоговый счёт пересчитывается сам. Меняй код и запускай снова.Computed-сигналы ленивы и кэшируются. Это два ключевых свойства производительности.
Контекст. Покажем на примере «тяжёлого» computed, который пишет в консоль при каждом реальном пересчёте, чтобы увидеть кэширование в действии.
const heavy = computed(() => {
console.log('пересчёт');
return tasks().filter(...).length;
});
heavy(); // «пересчёт» — первое чтение
heavy(); // тишина — используется кэш
tasks.update(...); // источник изменился
heavy(); // «пересчёт» — снова вычислено
heavy() реально считает и печатает «пересчёт». Шаг 2 — второй вызов берёт закэшированный результат, печати нет. Шаг 3 — меняем источник tasks. Шаг 4 — следующий вызов снова пересчитывает и печатает.Что увидит пользователь: в консоли «пересчёт» появится только при реальном изменении данных, а не при каждом чтении.
Это выгодно отличает computed от обычного метода-геттера, который пересчитывается при каждом вызове.
visibleTasks() за одну перерисовку, а список не менялся, повторные чтения не тратят ресурсы — они берут кэш. Следите за тем, чтобы внутри computed не было побочных действий (подробнее в разделе 8).effect() выполняет функцию каждый раз, когда меняется любой сигнал, прочитанный внутри неё. Это место для побочных эффектов: запись в localStorage, отправка в сервис, логирование.
effect() — функция, которая выполняется при изменении любого прочитанного внутри неё сигнала; место для побочных действий (сохранение, логи, сеть).Контекст. Самый простой эффект — пишет в консоль при каждом изменении фильтра.
import { effect, signal } from '@angular/core';
const filter = signal('all');
effect(() => {
console.log('Текущий фильтр:', filter());
});
// при первом запуске: «Текущий фильтр: all»
filter.set('done');
// «Текущий фильтр: done» — сработал снова
filter. Шаг 2 — effect читает filter() и сразу выполняется (печатает «all»). Шаг 3 — filter.set('done') меняет значение. Шаг 4 — эффект видит изменение и печатает «done».Что увидит пользователь: в консоли две записи: сначала про «all», потом про «done».
Эффект отслеживает, какие сигналы он прочитал, и перезапускается, когда они меняются.
Контекст. Реальный пример — автосохранение списка задач в браузере, чтобы при перезагрузке данные не пропали.
// сохранение в localStorage при изменении списка
effect(() => {
// читаем this.tasks() → эффект «подписан» на этот сигнал
localStorage.setItem('tasks', JSON.stringify(this.tasks()));
});
this.tasks(), значит «подписан» на этот сигнал. Шаг 2 — при первом запуске он сразу сохраняет текущий список. Шаг 3 — при любом изменении списка эффект срабатывает снова и перезаписывает хранилище.Что увидит пользователь: сам по себе — ничего на экране, но данные сохраняются «за кадром» и восстановятся после перезагрузки.
a() и пишет в b(), а какая-то другая связка ведёт обратно к a(), может возникнуть цикл. Держите эффекты простыми и односторонними.Эффекты решают узкую задачу. Чтобы не злоупотребить, применяйте их по чёткому признаку: побочное действие, не участвующее в формировании 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);
computed(). Эффекты — только для «побочностей» вовне (хранилище, сеть, логи). Если вы поймали себя на изменении сигнала в effect ради перерисовки — переведите это в computed.В шаблоне сигнал читается вызовом со скобками, как и в TypeScript-коде.
Контекст. В шаблоне Angular пишем те же вызовы, что и в коде: {{ ...() }}. Это то, что видит пользователь (DOM).
<h2>Задач: {{ tasks().length }}</h2>
<p>Фильтр: {{ filter() }}</p>
<!-- computed тоже вызывается -->
<p>Активных: {{ activeCount() }}</p>
{{ tasks().length }} читает сигнал и берёт длину. Шаг 2 — {{ filter() }} показывает текущий фильтр. Шаг 3 — {{ activeCount() }} читает производное значение. Все три обновляются сами при изменении.Что увидит пользователь: заголовок с числом задач, текущий фильтр и счётчик активных, которые меняются в реальном времени.
Для списка через @for:
Контекст. Цикл перебирает элементы производного списка visibleTasks() и для каждого рисует дочерний компонент задачи.
@for — как конвейер на почте: для каждой посылки (задачи) выполняется одно и то же действие (показать карточку).@for (task of visibleTasks(); track task.id) {
<app-task-item [task]="task" />
}
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).
В традиционном подходе разработчик вручную находил элемент и менял его. С сигналами Angular сам определяет, что изменилось, и обновляет только затронутые узлы.
Механизм в общих чертах:
set()/update().Контекст. Две строки зависят от разных сигналов — Angular обновит только ту, что изменилась.
<p>Всего: {{ tasks().length }}</p> <!-- зависит от tasks -->
<p>Фильтр: {{ filter() }}</p> <!-- зависит от filter -->
tasks. Шаг 2 — вторая «подписана» на filter. Шаг 3 — если поменялся только фильтр, обновится вторая строка.Что увидит пользователь: при смене фильтра поменяется текст фильтра, а число задач останется прежним (и наоборот).
Если поменяется только filter, Angular обновит вторую строку, а первую не тронет. Это «тонкая» перерисовка, точечная, а не полная перезагрузка страницы.
Существует также классический механизм change detection (CD). Современные сигналы интегрируются с ним, но точную стратегию перерисовки вы можете влиять (например, onPush, урок 13). Для этого урока достаточно понимать идею зависимости «шаблон ← сигнал».
Чтобы 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)
);
[...cur, task] копирует старые задачи и добавляет новую (новая ссылка). Шаг 2 — filter создаёт массив без нужной задачи (новая ссылка). Шаг 3 — map для нужной задачи делает копию через spread, меняя только completed; остальные оставляет как есть. Шаг 4 — сигнал видит новую ссылку и уведомляет подписчиков.Что увидит пользователь: задача появится/исчезнет/переключится, и счётчики пересчитаются сами.
Контекст. Показываем, почему «правка на месте» ломает реактивность: ссылка не меняется, и Angular «думает», что ничего не произошло.
push меняет массив внутри, но ссылка остаётся прежней.// ПЛОХО — мутация, сигнал не узнает об изменении
const list = this.tasks();
list.push(task);
// ссылка та же — Angular может не перерисовать
this.tasks() возвращает сам массив (ссылку). Шаг 2 — push добавляет элемент внутрь, но ссылка не меняется. Шаг 3 — сигнал «думает», что ничего не изменилось, и DOM не обновляется.Что увидит пользователь: ничего — новая задача не появится на экране, хотя в памяти она есть. Это типичный «немой» баг.
Ключ к пониманию: сравнение ссылок. Новый массив — новая ссылка — сигнал «увидел» изменение. Тот же массив с изменённым содержимым — старая ссылка — реактивность может пропустить изменение.
Объект внутри массива тоже обновляют копированием через spread, а не присваиванием полю.
Контекст. Переключение флага completed у конкретной задачи — классический пример неизменяемого обновления объекта.
// переключить completed в конкретной задаче
this.tasks.update(cur =>
cur.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
)
);
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' }
{ ...task } копирует все поля оригинала. Шаг 2 — перечисленные после него поля (title, completed, priority) перезаписывают копию. Шаг 3 — результат — новый объект, старый не изменился.Старый объект не меняется — он остаётся в памяти, пока нужен. Это делает обновления безопасными и соответствующими принципу неизменяемости из урока 2 и 15 урока 4.
Классическая задача TaskFlow — показать список по фильтру. Реализуем её через computed на основе методов из урока 2 (раздел 9).
Контекст. Один computed visibleTasks решает, какие задачи показывать в зависимости от выбранного фильтра. Значение фильтра — только 'all' | 'active' | 'done'.
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();
});
f. Шаг 2 — если 'active', оставляем задачи, где completed ложно. Шаг 3 — если 'done', оставляем выполненные. Шаг 4 — иначе возвращаем весь список. Результат пересчитывается сам при смене фильтра или списка.Что увидит пользователь: список подстроится под выбранный фильтр без перезагрузки страницы.
Шаблон использует visibleTasks():
Контекст. В шаблоне просто перебираем уже готовый отфильтрованный список.
@for (task of visibleTasks(); track task.id) {
<app-task-item [task]="task" />
}
visibleTasks() возвращает массив, отобранный computed. Шаг 2 — @for рисует карточку для каждой задачи. Шаг 3 — при смене фильтра computed даёт новый массив, шаблон перерисовывается.Что увидит пользователь: ровно те задачи, которые соответствуют фильтру.
В уроках 4–5 filter() — это вызов сигнала, а не метод. Две разные сущности: сигнал filter (тип TaskFilter) и метод массива filter(). Не путайте имена.
visibleTasks() в уроке 4 пересчитывался при каждом вызове и должен был вызываться вручную. 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);
total берёт длину списка. Шаг 2 — activeCount считает задачи с completed === false. Шаг 3 — doneCount считает выполненные. Все три пересчитываются сами при изменении tasks.В шаблоне статистика:
Контекст. В шаблоне просто выводим значения счётчиков — они всегда актуальны.
<div class="stats">
<span>Всего: {{ total() }}</span>
<span>Активных: {{ activeCount() }}</span>
<span>Выполнено: {{ doneCount() }}</span>
</div>
{{ total() }} читает первый счётчик. Шаг 2 — {{ activeCount() }} и {{ doneCount() }} читают остальные. Шаг 3 — при отметке задачи счётчики меняются сами.Что увидит пользователь: три числа, которые меняются сами при отметке задач (например, отметили — «Выполнено» растёт, «Активных» падает).
Счётчики автоматически следуют за списком. Отметьте задачу — doneCount() вырастет, activeCount() уменьшится без единой строчки «обновления счётчика».
total()/active()/done() в StatsBar. Теперь те же вычисления — readonly computed, их можно вынести куда угодно и они следят за источником сами.Помимо filter, полезны find и reduce (урок 2, раздел 9).
Контекст. Выбираем одну задачу по её id — например, чтобы показать карточку выбранной.
find() — как поиск одной записи по её номеру (id) в списке: назвали номер, нашли нужную и остановились.const selectedTask = computed(() =>
tasks().find(t => t.id === selectedId()) ?? null
);
find ищет первую задачу с нужным id. Шаг 2 — если не найдено, возвращается undefined. Шаг 3 — ?? null превращает его в явный null, с которым удобнее работать в шаблоне.find возвращает первый подходящий элемент или undefined. Оператор ?? null нормализует в явный null, если задачи нет.
Контекст. Считаем, сколько задач с высоким приоритетом — одним проходом по массиву.
reduce() — как свёртка чека в одну итоговую сумму: проходим по всем позициям и накапливаем результат в одной переменной.const highCount = computed(() =>
tasks().reduce((acc, t) => acc + (t.priority === 'high' ? 1 : 0), 0)
);
acc (аккумулятор) начинается с 0. Шаг 2 — для каждой задачи прибавляем 1, если приоритет 'high', иначе 0. Шаг 3 — итог накапливается в acc и становится результатом.Что увидит пользователь: число задач с высоким приоритетом, обновляющееся само.
reduce сворачивает массив в одно значение. Здесь считаем задачи с приоритетом high.
find — одна первая запись (например, открытая задача по id). filter — коллекция (список по критерию). reduce — скаляр (сумма, количество). Выбор отражает намерение и результат.Сортировка — тоже производное значение. Важно не сортировать исходный массив, а работать с копией.
Контекст. Сортируем задачи по приоритету: high выше всего, low ниже всех.
const sortedByPriority = computed(() => {
const rank = { high: 0, medium: 1, low: 2 };
return [...tasks()].sort((a, b) => rank[a.priority] - rank[b.priority]);
});
[...tasks()] делает копию массива. Шаг 3 — sort сортирует копию по разнице весов. Шаг 4 — возвращаем отсортированную копию, исходный сигнал не тронут.Что увидит пользователь: список, где сначала high, потом medium, потом low.
Заметьте [...tasks()]: spread создаёт копию, и sort() мутирует копию, а не исходный сигнал. Такой подход безопасен.
Array.prototype.sort изменяет массив на месте. Поэтому перед сортировкой всегда копируйте: [...arr].sort(...). Иначе вы «мутируете» значение внутри computed, что опасно для неизменяемости.Контекст. Ещё один пример — сортировка по названию с учётом языка (русского алфавита).
// сортировка по названию (locale-aware)
const sortedByTitle = computed(() =>
[...tasks()].sort((a, b) => a.title.localeCompare(b.title))
);
[...tasks()] копия массива. Шаг 2 — localeCompare сравнивает строки по правилам языка. Шаг 3 — результат — отсортированная копия (исходник не изменён).Что увидит пользователь: задачи, упорядоченные по алфавиту названий.
Computed могут зависеть от других computed. Это позволяет строить цепочки вывода.
const visibleTasks = computed(() => { ... }); // фильтр
const sortedVisible = computed(() => [...visibleTasks()].sort(...));
const visibleCount = computed(() => sortedVisible().length);
Здесь visibleCount зависит от sortedVisible, тот — от visibleTasks, тот — от исходных сигналов. Изменение любого источника «пробегает» по цепочке.
Angular отслеживает эти зависимости и пересчитывает только то, что изменилось.
В уроке 4 мы писали методы-геттеры. Разберём разницу с computed.
| Критерий | Метод (геттер) | computed |
|---|---|---|
| Когда вычисляется | при каждом вызове | лениво, при первом чтении после изменения источников |
| Кэширует результат | нет | да |
| Реактивность | нет (пересчёт вручную) | да (следит за источниками) |
| Чтение | visibleTasks() | visibleTasks() |
// метод-геттер — пересчитывается каждый вызов
visibleTasks() { ... }
// computed — кэшируется и реактивен
readonly visibleTasks = computed(() => ...);
Когда годится метод: если вычисление должно выполняться заново при каждом вызове по иной причине (например, время). Для производных значений от сигналов — computed почти всегда лучше.
visibleTasks() совпадает — но за ним разная семантика: вызов метода vs чтение сигнала. Помните об этом, читая код.Соберём реактивную модель 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);
}
}
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>
[filter]="filter()" передаёт текущий фильтр дочернему бару. Шаг 2 — (filterChange) ловит выбор пользователя и зовёт onFilterChange. Шаг 3 — @for рисует видимые задачи. Шаг 4 — счётчики читаются как производные значения.Что увидит пользователь: рабочий экран со списком, фильтром и живыми счётчиками.
map, сигнал меняется, счётчик пересчитывается сам. Angular-версия — через ng serve.Идея реактивности есть и в React, но синтаксис иной. Проведём параллель (урок 1, раздел 24).
| Задача | Angular | React |
|---|---|---|
| Состояние | 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]
);
useState хранит список. Шаг 2 — useMemo пересчитывает visibleTasks, только когда меняется tasks (зависимость в массиве). Шаг 3 — результат используется в шаблоне React.Самый частый источник «немой» ошибки — мутировать объект или массив внутри сигнала и не получить перерисовку.
Контекст. Самый частый источник «немой» ошибки: правим объект «на месте» вместо создания нового. Плохой пример меняет поле напрямую; хороший — создаёт новый объект.
// ПЛОХО
const task = this.tasks()[0];
task.completed = true; // мутация внутри объекта
// сигнал не «узнал» об изменении — ссылка не поменялась
// ХОРОШО
this.tasks.update(cur =>
cur.map((t, i) => i === 0 ? { ...t, completed: true } : t)
);
completed напрямую. Шаг 3 — ссылка массива не изменилась → нет перерисовки. Хороший: map создаёт новый массив и новый объект для нужной задачи → сигнал срабатывает.Ещё одна ловушка — мутировать коллекцию внутри computed:
Контекст. Ещё одна ловушка — мутировать коллекцию внутри computed. Плохой вариант сортирует сам сигнал; хороший — его копию.
// ПЛОХО: sort мутирует массив внутри computed
const bad = computed(() => tasks().sort((a, b) => ...));
// tasks() отдаёт исходный массив, sort его портит
// ХОРОШО: сортируем копию
const good = computed(() => [...tasks()].sort((a, b) => ...));
tasks() возвращает исходный массив. Шаг 2 — sort меняет его на месте, портя состояние. Хороший: [...tasks()] делает копию, и sort мутирует только её; исходный сигнал цел.Что увидит пользователь при плохом варианте: список может «сломаться» или перестать обновляться, потому что сам источник испорчен.
Список опасных мутирующих методов: push, pop, splice, sort, reverse, присваивание полю объекта. Заменяйте их на spread/map/filter.
Создадим компонент, где список задач — сигнал, и опробуем чтение в шаблоне.
Контекст. Команда Angular CLI создаёт каркас компонента (файлы и папку).
ng generate component task-list
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 },
]);
}
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>
{{ tasks().length }} показывает число задач. Шаг 2 — @for перебирает элементы массива. Шаг 3 — для каждой задачи показываем заголовок и статус. Шаг 4 — при изменении сигнала шаблон обновляется сам.Что увидит пользователь: заголовок «Задач в списке: 2» и два пункта со статусами ✅/⬜.
Шаблон читает tasks(), а внутри @for перебирает элементы. Убедитесь, что в браузере появятся заголовок и два пункта.
Свяжем чекбокс с сигналом: переключение меняет состояния, шаблон перерисовывается автоматически.
Контекст. Метод 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>
}
[checked] показывает текущее значение completed. Шаг 2 — при клике срабатывает (change). Шаг 3 — onToggled меняет сигнал. Шаг 4 — Angular перерисовывает чекбокс и счётчики.Что увидит пользователь: отметил чекбокс — галочка появилась/исчезла, без ручного обновления.
Отметьте чекбокс — пункт получит другой checked. Изменение прошло: событие → update() → новый массив → перерисовка.
[class.done]="task.completed" и в CSS зачёркивать выполненные. Так вы соедините сигнал и стили (урок 3, разделы про class binding).Добавим фильтр и 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>
}
setFilter с разным значением. Шаг 2 — setFilter пишет в сигнал filter. Шаг 3 — visibleTasks() пересчитывается. Шаг 4 — @for рисует отфильтрованный список.Что увидит пользователь: нажал «Выполненные» — в списке остались только сделанные задачи.
Выберите «Выполненные» — в списке останутся только сделанные. Изменение фильтра автоматически пересчитало visibleTasks().
Добавим счётчики как производные значения.
Контекст. Три счётчика как производные значения — они считаются из списка и обновляются сами.
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
);
total = длина списка. Шаг 2 — activeCount = число с completed === false. Шаг 3 — doneCount = число выполненных. Все пересчитываются сами при изменении списка.Контекст. Выводим счётчики в шаблоне — они всегда актуальны.
<p>Всего: {{ total() }} · Активных: {{ activeCount() }} · Выполнено: {{ doneCount() }}</p>
{{ ...() }} читает свой computed. Шаг 2 — при отметке задачи tasks меняется. Шаг 3 — счётчики пересчитываются и шаблон обновляется.Что увидит пользователь: помечает задачи чекбоксом — счётчики меняются сами, без ручного пересчёта. Это демонстрация реактивности в действии.
Реализуем выбор одной задачи по 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); }
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>
}
@if проверяет, что selectedTask() не пуст. Шаг 2 — as task сохраняет результат в переменную. Шаг 3 — показываем карточку. Шаг 4 — иначе показываем подсказку.Что увидит пользователь: выбрал задачу — открылась её карточка; ничего не выбрано — подсказка.
Новый синтаксис @if (expr; as alias) сохраняет результат в локальную переменную task, чтобы не вызывать selectedTask() многократно.
undefined от некорректного id в явный null — проще проверять и типизировать (урок 2, раздел 37 про null/undefined).Объединим фильтрацию и статистику в каскад 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)
);
visibleCount берёт длину отфильтрованного списка. Шаг 2 — doneShare проверяет деление на ноль. Шаг 3 — считает долю выполненных в процентах. Шаг 4 — всё пересчитывается при изменении источников.Контекст. Выводим два показателя статистики.
<p>Показано записей: {{ visibleCount() }}</p>
<p>Доля выполненных: {{ doneShare() }}%</p>
{{ visibleCount() }} читает первый computed. Шаг 2 — {{ doneShare() }} читает второй. Шаг 3 — при изменении списка/фильтра оба обновляются.Что увидит пользователь: число видимых задач и процент выполнения, меняющиеся сами.
Здесь visibleCount зависит от visibleTasks, а doneShare — от doneCount и total. Любое изменение источника пробегает по каскаду.
Не только списки — отдельные значения (флажок, текст поля) тоже бывают сигналами.
Контекст. Два отдельных сигнала: открыт ли блок, и что введено в поле. Это удобно, когда значение читается в шаблоне или участвует в вычислениях (например, можно ли отправить форму).
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 читает newTitle(). Шаг 2 — проверяет, что после trim длина больше 0. Шаг 3 — кнопка привязана к !canSubmit(): пустое поле → кнопка выключена.Что увидит пользователь: кнопка «Добавить» серая и неактивная, пока поле пустое, и становится активной, стоит начать вводить текст — автоматически.
Сигналы не отменяют архитектуру урока 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).Соберём полную картину того, что происходит при клике пользователя.
Три шага повторяются для любого изменения состояния. Поняв их, вы сможете предсказать поведение любого сигнала в приложении.
Разберём два реалистичных применения effect().
Контекст. Эффект в конструкторе компонента автоматически сохраняет список задач в браузер при каждом его изменении — без ручных вызовов «сохранить».
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()));
});
}
}
this.tasks(), значит «подписан» на него. Шаг 2 — при первом запуске сразу сохраняет текущий список. Шаг 3 — при любом изменении списка эффект срабатывает снова.Что увидит пользователь: сам по себе — ничего на экране, но данные сохраняются «за кадром» и восстановятся после перезагрузки.
Каждый раз, когда tasks меняется, эффект записывает новую версию. Никаких ручных вызовов «сохранить» — изменение состояния само запускает побочное действие.
Контекст. Второй пример — логирование: при смене фильтра пишем в консоль. Это тоже побочное действие «наружу».
readonly filter = signal<TaskFilter>('all');
constructor() {
effect(() => {
console.log('Фильтр изменён на:', this.filter());
});
}
filter(). Шаг 2 — при создании компонента срабатывает сразу (печатает «all»). Шаг 3 — при смене фильтра печатает новое значение.Что увидит пользователь: в консоли разработчика — записи о смене фильтра (сам экран не меняется от лога).
Эффект удобен, но не перегружайте им компонент. В уроке 6, вынося состояние в сервис, эффекты тоже переносите вместе с логикой.
Углубим статистику: посчитаем несколько показателей через reduce, чтобы не писать повторяющийся filter.
Контекст. Один проход по массиву через reduce даёт сразу все показатели статистики (вместо нескольких отдельных filter).
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 }
)
);
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).В существующих 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 в pipes | computed() |
Вы могли заметить, что вызовы computed() и effect() выполняются в теле компонента (в поле или в конструкторе), а не внутри произвольной функции. Это связано с контекстом инъекций.
Angular создаёт эффект в «реактивном контексте» текущего компонента, чтобы связать его с жизненным циклом этого компонента. Когда компонент уничтожается, его эффекты тоже автоматически прекращаются — не нужно вручную отписываться.
// эффект связан с жизненным циклом компонента
export class TaskList {
constructor() {
effect(() => { /* живёт, пока жив компонент */ });
}
}
Если вызвать effect() вне такого контекста, Angular выдаст ошибку «effect() can only be used within an injection context». Это частая неожиданность.
Закрепим таблицу сравнения из раздела 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 signal | React state |
|---|---|---|
| Чтение | вызов () | прямое значение |
| Обновление от предыдущего | update(fn) | setState(prev => ...) |
| Пересчёт производного | композируется автоматически | пересборка при каждом рендере + useMemo |
| Производительность перерисовки | точечная (зависимости) | пересборка компонента |
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. Изменение любого из них пересчитывает результат.
Неизменяемость начинается с типа. Объявим модель так, чтобы компилятор помогал её соблюдать.
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. Здесь тот же инструмент применяется к полю модели.
В уроке 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() в родителе и передачу готовых величин.Что можно хранить в сигнале? Все что угодно: примитив, массив, объект, даже другой сигнал (хотя это редкость). Структура влияет на то, как вы его обновляете.
| Тип значения | Обновление | Пример |
|---|---|---|
| Примитив (string/number/boolean) | set(newValue) | filter.set('done') |
| Массив | новая ссылка через spread/map/filter | update(a => [...a, task]) |
| Объект | новый объект через spread | update(o => ({ ...o, completed: true })) |
Объект в сигнале — например, целое состояние формы:
const form = signal({ title: '', priority: 'low', completed: false });
// обновить одно поле — новый объект (поле задачи — completed, не done)
form.update(f => ({ ...f, priority: 'high' }));
Хранение целого объекта удобно, когда поля обновляются вместе. Для независимых полей чаще держат отдельные сигналы (урок 28.1).
Фильтрация и подсчёты — чистая логика. Её можно вынести из компонента в обычные функции, чтобы переиспользовать и тестировать.
Контекст. Чистую логику (фильтрация, подсчёт, сортировка) выносим в обычные функции-утилиты, чтобы переиспользовать и тестировать их без 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]);
}
filterTasks возвращает отобранный массив. Шаг 2 — countDone считает выполненные. Шаг 3 — sortByPriority сортирует копию. Все функции чистые: на тех же входах — тот же результат.В компоненте computed просто вызывает утилиты:
readonly visibleTasks = computed(() =>
filterTasks(this.tasks(), this.filter())
);
tasks() и filter(). Шаг 2 — передаёт их в filterTasks. Шаг 3 — результат становится значением сигнала. Шаг 4 — пересчёт происходит автоматически при изменении любого источника.Такой слой делает computed лаконичным, а логику — переносимой и тестируемой отдельно от Angular.
Вычисление может подготавливать данные для формы или интерфейса. Например, префил нового названия или подсказка.
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' ? 'Новая задача (завершена)' : 'Новая задача'
);
Фильтр и видимый список связаны: изменение одного автоматически обновляет другое. Это и есть главное преимущество реактивности.
// состояние
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) { ... }
Добавим поле поиска и связанный с ним 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 даёт комбинированный результат. При работе с большими списками такое сочетание фильтрует сразу по двум критериям.
Пока реактивное состояние живёт в одном компоненте, сигналы и computed в нём уместны. Но есть два признака, что пора выносить в сервис (урок 6).
// сейчас: сигнал внутри компонента
export class TaskList {
readonly tasks = signal<Task[]>([]);
}
// урок 6: сигнал в сервисе, компонент читает его через DI
@Injectable({ providedIn: 'root' })
export class TaskService {
readonly tasks = signal<Task[]>([]);
}
Сигналы и 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) очевиден: чистые функции тестируются всего строкой импорта, без монтирования компонента.
Сигналы комфортны на обычных объёмах. Но если список огромный, важно понимать, что вызывает пересчёт.
// каждый computed фильтрует весь массив заново
const highCount = computed(() =>
this.tasks().filter(t => t.priority === 'high').length
);
Фильтрация массива — O(n). На тысячах задач это мгновенно; на сотнях тысяч — ощутимо. Приёмы оптимизации:
Полный цикл реактивности замыкается: события дочерних компонентов поднимаются наверх и меняют сигнал, а 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-результат используется в шаблоне несколько раз, можно сохранить его в локальную переменную через @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. Если один раз и рядом по коду — можно и напрямую. Баланс читаемости и краткости.Некоторые сигналы могут быть «пустыми» или иметь значение по умолчанию. Поработаем с этим аккуратно.
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 — явно «нет значения»; undefined — «не присвоено»; пустая строка '' — «пустой ввод». Для сигналов выбирайте смысл заранее: опциональный выбор — null, строковый поиск — ''.Финальное упражнение соединяет все приёмы: сигналы состояния, каскад 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. Никакой ручной синхронизации: меняется любой источник — список пересчитывается.
Соединим форму и список через сигналы в полный цикл: ввод → событие → обновление сигнала списка → пересчёт производных.
Контекст. Полный сценарий формы на сигналах: поле ввода — сигнал, кнопка блокируется через 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(''); // очистка поля
}
}
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]);
}
created с заголовком. Шаг 2 — вычисляет новый уникальный id. Шаг 3 — update создаёт НОВЫЙ массив с задачей в начале (новая ссылка). Шаг 4 — сигнал срабатывает, список и счётчики обновляются.Что увидит пользователь: новая задача появляется вверху списка, счётчики пересчитываются автоматически.
Кнопка автоматически активна/неактивна в зависимости от поля — через canSubmit(). Очистка поля после отправки делает [disabled] снова истинным. Всё реактивно, без ручного контроля.
newTitle = ''. Здесь тот же результат достигается сигналом, что открывает путь к произведным вроде canSubmit. Обе реализации допустимы; сигнал добавляет реактивность формам.Соберём всё, что умеет делать реактивное состояние TaskFlow к концу урока.
| Задача | Как реализовано |
|---|---|
| Хранение списка | signal<Task[]>([]) |
| Выбранный фильтр | signal<TaskFilter>('all') |
| Строка поиска | signal('') |
| Отфильтрованный список | computed(() => ...) |
| Счётчики | computed(() => filter(...).length) |
| Выбранная задача | computed(() => find(...)) |
| Сохранение изменения | effect(() => localStorage...) |
| Обработка событий | методы обновляют сигналы (set/update) |
Итоговая картина — единый «источник правды» (сигналы) и её реактивные «отражения» (computed). Компоненты лишь читают отражения и сообщают о намерениях.
Состояние (сигналы)
│ читают
▼
Производные (computed)
│ кормят
▼
Шаблон (декларативно показывает)
▲
│ событийный вызов
Методы-обработчики → обновляют сигналы
Семь заданий на реактивное состояние. Сначала попробуйте сами, затем сверьтесь с подсказками в разделе 30.
Создайте computed(), который возвращает количество выполненных задач, и выведите в шаблон.
Добавьте три кнопки фильтра и visibleTasks через computed. Проверьте переключение.
Выведите процент выполненных задач через computed, как строку «42%».
Реализуйте selectedTask через find и отобразите карточку выбранной задачи.
Добавьте sortedTasks, копирующий и сортирующий список по приоритету.
Добавьте effect(), который при изменении списка пишет JSON в localStorage.
Сделайте кнопку «Добавить» неактивной, пока поле названия пустое, через computed.
Совет: сначала сформулируйте идею, затем открывайте код.
readonly doneCount = computed(() => this.tasks().filter(t => t.completed).length);, вывод {{ doneCount() }}.if (this.filter() === 'active') ..., не путайте сигнал filter и метод filter() массива.Math.round((doneCount() / total()) * 100) с проверкой total() === 0, чтобы не делить на ноль.this.tasks().find(t => t.id === this.selectedId()) ?? null и @if (selectedTask(); as task).[...this.tasks()].sort((a,b) => rank[a.priority] - rank[b.priority]) — копия перед sort.effect(() => localStorage.setItem('tasks', JSON.stringify(this.tasks()))).canSubmit = computed(() => this.newTitle().trim().length > 0), [disabled]="!canSubmit()".Разберём типичные проблемы, которые возникают при работе с сигналами.
| Симптом | Причина | Решение |
|---|---|---|
| Список не обновляется после set | Мутация исходного массива/объекта | Всегда новая ссылка: spread/map/filter |
| {{ tasks }} | Забыты скобки вызова сигнала | {{ tasks() }} |
| effect() вне контекста | Вызов в свободной функции | Вызывать в компоненте/сервисе |
| computed пересчитывается при каждом чтении | Внутри побочный вызов/нестабильность | Держать computed «чистым» от побочных эффектов |
| Бесконечный цикл | Эффект пишет в сигнал, который читает | Разорвать зависимость; эффекты — наружу |
| sort испортил данные | sort мутирует массив из сигнала | Сортировать копию [...arr] |
push/splice/sort и не присваивайте поля объектам внутри. Создавайте новые ссылки.Пять правил реактивности в Angular.
signal(); читается вызовом ().set() (новое значение) или update() (от текущего).computed(): лениво, с кэшированием, реактивно.effect(), но только «наружу» (хранилище, логи, сеть).Когда эти правила становятся привычными, TaskFlow на сигналах пишется и читается легко: состояние — в сигналах, вывод — в computed, а шаблон лишь отражает результат. Любая перестройка данных, к которой вы придёте в уроках 6–12 (сервисы, HTTP, формы, маршрутизация), сохранит эту фундаментальную схему.
Ответьте сначала своими словами, затем сверьтесь с конспектом. Если не можете объяснить — перечитайте соответствующий раздел.
signal()?set(), а когда update()?computed(), и почему он «ленивый»?asReadonly()?sort() нужна копия массива?effect(), а когда нет?| Термин | Определение |
|---|---|
| Состояние (state) | Данные, которые хранятся и могут меняться. |
| Производное значение | Данные, вычисляемые из состояния (не хранятся). |
| signal() | Реактивный контейнер значения. |
| set() / update() | Способы записать/вычислить новое значение сигнала. |
| computed() | Реактивное производное значение с кэшированием. |
| effect() | Побочное действие при изменении прочитанных сигналов. |
| Реактивность | Способность значения автоматически следовать за источниками. |
| Неизменяемое обновление | Создание новой ссылки вместо мутации. |
| Spread { ... } | Оператор копирования полей объекта/элементов массива. |
| Кэширование | Повторное использование ранее вычисленного результата. |
| readonly-сигнал | Сигнал, доступный только для чтения (asReadonly). |
| Контекст инъекций | Область выполнения, где Angular разрешает effect()/computed(). |
| @let | Локальная переменная в шаблоне из выражения (например, из computed). |
| asReadonly() | Метод сигнала, отдающий доступное только для чтения представление. |
| Derived / производное | Значение, вычисляемое из состояния, а не хранимое отдельно. |
| Source of truth | Единственный авторитетный источник значения в приложении. |
Для самостоятельного углубления:
signal() и читаю вызовом ().set() и update() по зависимости от текущего значения.computed().effect() только для побочных действий.Замените ручные пересчёты на сигналы и computed.
const tasks = signal<Task[]>([ /* ... */ ]);
const doneCount = computed(() =>
tasks().filter(t => t.completed).length);
tasks.update(list => [...list, newTask]); // счётчики пересчитались сами
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();
});
| Термин | Жизненная аналогия | Коротко |
|---|---|---|
signal | группа в мессенджере | контейнер со значением; при изменении уведомляет всех подписчиков |
computed | итоговый счёт в игре | производное значение, пересчитывается само из сигналов-источников |
effect | будильник | реагирует на изменение сигнала; место для побочных действий (сохранение, логи) |
| состояние (state) | статус заказа | данные, которые хранятся и меняются со временем (список задач, фильтр) |
| неизменяемость | новый снимок вместо правки оригинала | обновляем через map/filter/spread, чтобы сигнал заметил изменение |
filter | ситечко для чая | пропускает в новый массив только подходящие элементы |
map | конвейер | преобразует каждый элемент, создавая новый массив |
find | поиск по номеру | возвращает первый подходящий элемент или undefined |
1. Чтобы обновить значение computed(), нужно каждый раз вызывать его вручную при изменении данных. Верно или неверно?
Ответ: Неверно. computed() пересчитывается сам, как итоговый счёт в игре: стоит измениться источнику — результат обновляется автоматически.
2. В шаблоне Angular можно менять значение сигнала напрямую через set(). Верно или неверно?
Ответ: Неверно. Шаблон только читает сигналы (как витрина магазина — показывает товар, но не хранит его). Изменения делаются в методах компонента.
3. Если внутри сигнала-массива поменять один элемент «на месте», сигнал автоматически заметит изменение. Верно или неверно?
Ответ: Неверно. Нужна неизменяемость: создать новый снимок через spread, иначе сигнал «не видит» правку, как будто вы отредактировали оригинал фото, а не сделали копию.