Angular IDPStrong Junior
🔥 0

Модуль 2 · урок 4 з 15

Сигнали

Сигнал — це значення, яке знає, хто його читає. Звучить дрібно, але саме з цього випливає все інше: автоматичний перерахунок похідних значень, точкове перемальовування і зникнення половини хуків життєвого циклу. Це головна ідея сучасного Angular.

Три функції, з яких усе складається

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

const count = signal(0);              // 1. джерело: значення, за яким стежать
const double = computed(() => count() * 2);  // 2. похідне: рахується саме

effect(() => {                        // 3. побічна дія: щось робимо у відповідь
  console.log('лічильник став', count());
});

count();          // прочитати → 0
count.set(5);     // записати нове значення
count.update(v => v + 1);   // змінити на основі попереднього → 6
double();         // 12 — перерахувалось саме, нічого викликати не треба

Ключове тут — читання через виклик: count(), а не count. Саме в момент читання сигнал запам'ятовує, хто його читав. Коли значення зміниться, він скаже про це рівно тим, хто на нього дивився — і нікому більше.

signal filter = 'all' computed visibleTasks computed counter інший компонент, який filter не читає ПЕРЕМАЛЬОВУЄТЬСЯ лише таблиця й лічильник — ті шматки шаблону, що читали ці computed Сірий пунктир — не залежить, тому його не чіпають узагалі.
Граф залежностей будується сам, у момент читання. Тому зміна одного сигналу не призводить до перевірки всього застосунку — оновлюється рівно те, що від нього залежало.

Сигнали в компоненті

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

@Component({ /* … */ })
export class TaskList {
  readonly tasks = signal<Task[]>([]);
  readonly query = signal('');
  readonly onlyMine = signal(false);

  // похідне значення: перераховується, коли змінюється будь-що з трьох
  readonly visible = computed(() => {
    const q = this.query().toLowerCase().trim();
    return this.tasks()
      .filter(t => !this.onlyMine() || t.assigneeId === this.currentUserId())
      .filter(t => !q || t.title.toLowerCase().includes(q));
  });

  readonly total = computed(() => this.visible().length);
}
<p>Знайдено: {{ total() }}</p>
@for (task of visible(); track task.id) { <app-task-card [task]="task" /> }

Зверни увагу, чого тут немає: жодного ngOnChanges, жодного ручного перерахунку й жодної підписки. Ти описав залежність, а не послідовність дій — і в цьому головна різниця з тим, як писали раніше.

Незмінність: головна пастка

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

// ❌ так сигнал нічого не помітить: масив той самий об'єкт
tasks().push(newTask);

// ✅ новий масив — сигнал бачить зміну
tasks.update(list => [...list, newTask]);

// ✅ зміна одного елемента теж через новий масив
tasks.update(list => list.map(t =>
  t.id === id ? { ...t, status: 'done' } : t
));

Це та сама історія про копію значення й копію посилання, що була в конспекті до техдзвінка. Сигнал порівнює значення за посиланням: якщо посилання те саме, для нього нічого не сталось. Тому правило просте — ніколи не мутуй, завжди створюй нове.

effect: коли він потрібен і коли ні

constructor() {
  // доречно: побічна дія назовні
  effect(() => {
    localStorage.setItem('filter', this.query());
  });
}
Доречні побічні дії
  • Запис у localStorage.
  • Логування, аналітика.
  • Синхронізація з чимось поза Angular: карта, редактор, канвас.
  • Ручна робота з DOM, якої не уникнути.
Ознаки, що effect зайвий
  • Усередині рахується значення для іншого сигналу — це computed.
  • Усередині .set() іншого сигналу — майже завжди помилка.
  • Він потрібен, щоб «оновити шаблон» — шаблон оновиться сам.
  • Він робить HTTP-запит — для цього є resource і RxJS із модуля 4.

Просте правило: computed — коли ти обчислюєш, effect — коли ти щось робиш. Якщо всередині effect з'явився set, зупинись: майже завжди це маскує залежність, яку треба описати через computed.

linkedSignal: значення, яке залежить, але його можна перезаписати

// вибраний рядок таблиці скидається, коли змінився список,
// але користувач може вибрати інший вручну
readonly selected = linkedSignal<Task[], Task | null>({
  source: this.visible,
  computation: (tasks, prev) => {
    const keep = prev?.value && tasks.find(t => t.id === prev.value!.id);
    return keep ?? tasks[0] ?? null;
  },
});

selected.set(someTask);   // а так — вручну

computed перезаписати не можна, звичайний signal не вміє реагувати на джерело. linkedSignal закриває проміжок між ними — і це рівно той випадок, який виникне в модулі 5 з вибором рядка в таблиці.

Кілька дрібниць, які знадобляться

// прочитати сигнал, не підписуючись на нього
const snapshot = untracked(() => this.query());

// власне порівняння: не вважати зміною, якщо id той самий
const user = signal(initial, { equal: (a, b) => a.id === b.id });

// сигнал лише для читання назовні
private readonly _state = signal(0);
readonly state = this._state.asReadonly();
Сигнали чи RxJS

Коротка відповідь: сигнали — для стану, RxJS — для подій у часі. Значення, яке просто є (фільтр, вибраний рядок, режим теми) — сигнал. Потік, у якому важлива послідовність, затримки, скасування чи гонки запитів (пошук із debounce, HTTP, WebSocket) — RxJS. Вони не конкуренти: у модулі 4 буде toSignal() і toObservable(), які переводять одне в інше одним рядком.