Angular Signals та Computed у 2026: Тонка Реактивність та Питання Співбесід

Опануйте Angular Signals, computed, effect та linkedSignal в Angular 20+. Патерни тонкої реактивності та підготовка до співбесід.

Angular Signals та Computed у 2026: Тонка Реактивність та Питання Співбесід

Angular Signals представляють найзначнішу зміну в моделі реактивності Angular з моменту створення фреймворку. Представлені в Angular 17 і тепер стандартний підхід в Angular 20+, сигнали забезпечують тонку реактивність, що усуває непотрібні цикли виявлення змін та безперешкодно інтегрується з беззонними (zoneless) застосунками.

Ключова Інформація для Співбесіди

Сигнали є синхронними, вільними від глітчів реактивними примітивами. Computed signal перераховується лише коли змінюються його залежності і лише раз за цикл змін, незалежно від кількості прочитань.

Розуміння Примітивів Signal в Angular 20

Система сигналів Angular складається з трьох основних примітивів: signal(), computed() та effect(). Кожен слугує певній меті в реактивному графі.

Записуваний сигнал зберігає значення, яке можна оновити за допомогою .set() або .update(). Читання сигналу повертає поточне значення, а всі computed signals або effects, що залежать від нього, отримують сповіщення про зміни.

counter.component.tstypescript
import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  // Writable signal with initial value
  count = signal(0);
  
  // Computed signal derives from count
  // Only recalculates when count changes
  doubleCount = computed(() => this.count() * 2);
  
  increment(): void {
    // .update() receives current value as argument
    this.count.update(c => c + 1);
  }
}

Ключова відмінність від RxJS BehaviorSubject: сигнали синхронні та вільні від глітчів. Коли count змінюється, doubleCount перераховується рівно один раз, навіть якщо читається кілька разів у тому ж синхронному блоці.

Computed Signals: Ледаче Обчислення та Мемоізація

Computed signals кешують свій результат до зміни залежності. Ця мемоізація автоматична, на відміну від RxJS, де shareReplay або distinctUntilChanged потрібно додавати вручну.

user-stats.component.tstypescript
import { Component, signal, computed } from '@angular/core';

interface User {
  id: number;
  name: string;
  purchases: number[];
}

@Component({
  selector: 'app-user-stats',
  standalone: true,
  template: `
    <div>
      <p>User: {{ user().name }}</p>
      <p>Total spent: {{ totalSpent() | currency }}</p>
      <p>Average purchase: {{ averagePurchase() | currency }}</p>
    </div>
  `
})
export class UserStatsComponent {
  user = signal<User>({
    id: 1,
    name: 'Alice',
    purchases: [99.99, 149.50, 29.99]
  });
  
  // Computed tracks only the properties it reads
  totalSpent = computed(() => {
    const purchases = this.user().purchases;
    return purchases.reduce((sum, p) => sum + p, 0);
  });
  
  // Depends on another computed signal
  averagePurchase = computed(() => {
    const total = this.totalSpent();
    const count = this.user().purchases.length;
    return count > 0 ? total / count : 0;
  });
}

Computed signal обчислюється ледаче: функція обчислення виконується лише при першому читанні сигналу, а не при оголошенні. Подальші читання повертають кешоване значення до інвалідації залежністю.

Effect: Побічні Ефекти в Реактивному Графі

Effects виконують код у відповідь на зміни сигналів. Вони запускаються асинхронно під час циклу виявлення змін, після завершення всіх синхронних оновлень.

theme-sync.component.tstypescript
import { Component, signal, effect, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Component({
  selector: 'app-theme-sync',
  standalone: true,
  template: `
    <button (click)="toggleTheme()">Toggle Theme</button>
  `
})
export class ThemeSyncComponent {
  private document = inject(DOCUMENT);
  
  isDarkMode = signal(false);
  
  constructor() {
    // Effect syncs signal state to DOM attribute
    effect(() => {
      const theme = this.isDarkMode() ? 'dark' : 'light';
      this.document.documentElement.setAttribute('data-theme', theme);
    });
    
    // Effect for localStorage persistence
    effect(() => {
      localStorage.setItem('theme', this.isDarkMode() ? 'dark' : 'light');
    });
  }
  
  toggleTheme(): void {
    this.isDarkMode.update(dark => !dark);
  }
}

Effects відстежують залежності динамічно. Якщо умовна гілка не виконується, сигнали, що читаються лише в цій гілці, не відстежуються до зміни умови.

Антипатерн: Effect для Деривації Стану

Використання effect для копіювання даних з одного сигналу в інший вказує на проблему проектування. Для похідного стану слід віддавати перевагу computed або linkedSignal, коли похідне значення має бути записуваним.

linkedSignal: Записуваний Похідний Стан

Представлений в Angular 19 і стабільний в Angular 20, linkedSignal створює записуваний сигнал, що скидається при зміні його джерела. Він вирішує сценарії, де computed є лише для читання, але похідне значення потребує локальної модифікації.

pagination.component.tstypescript
import { Component, signal, linkedSignal, computed } from '@angular/core';

@Component({
  selector: 'app-pagination',
  standalone: true,
  template: `
    <select (change)="pageSize.set(+$any($event.target).value)">
      <option [value]="10">10 per page</option>
      <option [value]="25">25 per page</option>
      <option [value]="50">50 per page</option>
    </select>
    <p>Page {{ currentPage() }} of {{ totalPages() }}</p>
    <button (click)="prevPage()" [disabled]="currentPage() === 1">Prev</button>
    <button (click)="nextPage()" [disabled]="currentPage() === totalPages()">Next</button>
  `
})
export class PaginationComponent {
  totalItems = signal(243);
  pageSize = signal(10);
  
  totalPages = computed(() => 
    Math.ceil(this.totalItems() / this.pageSize())
  );
  
  // linkedSignal resets to 1 when pageSize or totalItems changes
  // but allows manual navigation via prevPage/nextPage
  currentPage = linkedSignal(() => 1);
  
  prevPage(): void {
    this.currentPage.update(p => Math.max(1, p - 1));
  }
  
  nextPage(): void {
    this.currentPage.update(p => Math.min(this.totalPages(), p + 1));
  }
}

Без linkedSignal цей патерн вимагав би або effect, що записує в інший сигнал (антипатерн), або складну оркестрацію RxJS.

Готовий до співбесід з Angular?

Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.

API Компонентів на Основі Сигналів: input, model, viewChild

Angular 20 переводить API компонентів на основі сигналів у стабільний статус. Вони замінюють декоратори реактивними альтернативами, що інтегруються в граф сигналів.

user-card.component.tstypescript
import { 
  Component, 
  input, 
  model, 
  output, 
  computed,
  viewChild,
  ElementRef
} from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div #card class="card" [class.selected]="selected()">
      <h3>{{ fullName() }}</h3>
      <button (click)="toggle()">{{ selected() ? 'Deselect' : 'Select' }}</button>
    </div>
  `
})
export class UserCardComponent {
  // Signal input replaces @Input()
  firstName = input.required<string>();
  lastName = input<string>('');
  
  // model() creates two-way bindable signal
  // Parent uses [(selected)]="parentSignal"
  selected = model(false);
  
  // Output emitter for events
  cardClicked = output<void>();
  
  // viewChild returns Signal<ElementRef | undefined>
  cardElement = viewChild<ElementRef>('card');
  
  // Computed derives from signal inputs
  fullName = computed(() => 
    `${this.firstName()} ${this.lastName()}`.trim()
  );
  
  toggle(): void {
    this.selected.update(s => !s);
    this.cardClicked.emit();
  }
}

Signal inputs забезпечують кращу типову безпеку: input.required() повертає InputSignal<T>, тоді як input() повертає InputSignal<T | undefined>, якщо не надано значення за замовчуванням.

Signals vs RxJS: Продуктивність Під Навантаженням

Бенчмарки в Angular 21+ показують, що сигнали зменшують накладні витрати виявлення змін у сценаріях з інтенсивним UI. При інтенсивних оновленнях сигнали продемонстрували чистіші профілі пам'яті та нижче використання CPU порівняно з прив'язками на основі RxJS.

Розподіл відповідальності чіткий:

Випадок ВикористанняРекомендований Підхід
Локальний стан компонентаsignal()
Похідні значення UIcomputed()
Синхронізація із зовнішніми APIeffect()
HTTP запитиRxJS Observable + toSignal()
Потоки WebSocketRxJS з операторами
Debounced ввід користувачаRxJS debounceTime, потім toSignal()
Складна async оркестраціяPipeline RxJS

RxJS залишається інструментом для асинхронних потоків, скасування та backpressure. Сигнали обробляють синхронний стан.

Інтероперабельність з RxJS: toSignal та toObservable

Angular надає утиліти в @angular/core/rxjs-interop для з'єднання сигналів та observables.

search.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap, distinctUntilChanged } from 'rxjs';

interface SearchResult {
  id: number;
  title: string;
}

@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input 
      [value]="query()" 
      (input)="query.set($any($event.target).value)"
      placeholder="Search..."
    />
    @if (results(); as items) {
      <ul>
        @for (item of items; track item.id) {
          <li>{{ item.title }}</li>
        }
      </ul>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);
  
  query = signal('');
  
  // Convert signal to observable for RxJS operators
  private query$ = toObservable(this.query);
  
  // RxJS pipeline with debounce, then back to signal
  results = toSignal(
    this.query$.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => q.length > 2 
        ? this.http.get<SearchResult[]>(`/api/search?q=${q}`)
        : []
      )
    ),
    { initialValue: [] as SearchResult[] }
  );
}

toSignal підписується при створенні та відписується при знищенні компонента. Опція initialValue усуває тип undefined з сигналу.

Питання Співбесід: Angular Signals

Технічні співбесіди у 2026 році часто перевіряють розуміння сигналів. Ось патерни, що відрізняють senior кандидатів.

П: Що відбувається, коли computed signal кидає помилку?

Помилка поширюється на кожен effect або computed, що його читає. Angular не кешує помилки — обчислення повторюється при наступному читанні. Продакшн код має обробляти помилки всередині функції computed.

П: Чи можна записувати в сигнал всередині computed?

Ні. Спроба викликати .set() або .update() всередині computed кидає помилку. Функції computed мають бути чистими. Слід використовувати linkedSignal, якщо потрібне похідне значення, яке також записуване.

П: Як effects обробляють читання сигналів в умовах?

typescript
// Dynamic dependency tracking example
const showDetails = signal(false);
const userDetails = signal({ name: 'Alice', email: 'alice@example.com' });

effect(() => {
  if (showDetails()) {
    // userDetails is only tracked when showDetails is true
    console.log('Details:', userDetails().email);
  }
});

Effects відстежують залежності на основі останнього виконання. Якщо showDetails є false, зміни в userDetails не викликають effect.

П: Як працює рівність сигналів?

За замовчуванням сигнали використовують Object.is для порівняння рівності. Для об'єктів це означає рівність за посиланням. Можна надати власну функцію рівності:

typescript
const user = signal(
  { id: 1, name: 'Alice' },
  { equal: (a, b) => a.id === b.id }
);
Порада для Співбесіди

Кандидати, які згадують, що computed signals мемоізовані та ледаче обчислювані, демонструють розуміння, що виходить за межі базового використання API. Обговорення того, коли використовувати linkedSignal vs computed, показує знайомство з патернами Angular 19+.

Виявлення Змін: Сигнали та Беззонний Angular

Angular 22 за замовчуванням використовує беззонне виявлення змін для нових проектів. Сигнали є центральними для цього: вони повідомляють Angular точно, коли змінюється стан, усуваючи потребу Zone.js патчити асинхронні API.

zoneless-counter.component.tstypescript
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-zoneless-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class ZonelessCounterComponent {
  count = signal(0);
  
  increment(): void {
    // Signal update triggers change detection
    // No Zone.js required
    this.count.update(c => c + 1);
  }
}

З сигналами Angular відстежує точно, які компоненти залежать від якого стану. Оновлення сигналу планує виявлення змін лише для уражених компонентів, а не всього дерева.

Resource API: Асинхронні Дані як Сигнали

Resource API Angular 20 (resource() та rxResource()) декларативно завантажує асинхронні дані в сигнали, замінюючи патерни ручної підписки.

user-profile.component.tstypescript
import { Component, signal, computed } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface User {
  id: number;
  name: string;
  email: string;
}

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (userResource.isLoading()) {
      <p>Loading...</p>
    }
    @if (userResource.error()) {
      <p>Error: {{ userResource.error() }}</p>
    }
    @if (userResource.value(); as user) {
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    }
  `
})
export class UserProfileComponent {
  private http = inject(HttpClient);
  
  userId = signal(1);
  
  // Declarative async resource
  // Refetches when userId changes
  userResource = rxResource({
    request: () => this.userId(),
    loader: ({ request: id }) => 
      this.http.get<User>(`/api/users/${id}`)
  });
  
  loadUser(id: number): void {
    this.userId.set(id);
  }
}

Resource автоматично відстежує стан завантаження, помилки та розв'язане значення. Він повторно отримує дані при зміні сигналу request.

Найкращі Практики Архітектури Сигналів

Структурування сигналів у великих застосунках вимагає дисципліни. Ці патерни масштабуються від компонентів до сервісів.

Колокація пов'язаних сигналів: Групування сигналів, що змінюються разом. Компонент форми тримає сигнали своїх полів разом, а не розкиданими по сервісах.

Переміщення спільного стану до сервісів: Коли кільком компонентам потрібен той самий стан, сигнал слід перемістити до injectable сервісу:

cart.service.tstypescript
import { Injectable, signal, computed } from '@angular/core';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);
  
  // Expose read-only signal to consumers
  readonly cartItems = this.items.asReadonly();
  
  readonly totalPrice = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  readonly itemCount = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );
  
  addItem(item: Omit<CartItem, 'quantity'>): void {
    this.items.update(items => {
      const existing = items.find(i => i.id === item.id);
      if (existing) {
        return items.map(i => 
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...items, { ...item, quantity: 1 }];
    });
  }
}

Перевага computed над effect: Якщо значення може бути похідним, слід використовувати computed. Effects призначені для побічних ефектів, що виходять за межі реактивного графа: маніпуляції DOM, localStorage, аналітика, мережеві запити.

Міграція з RxJS до Сигналів

Старі кодові бази Angular можуть мігрувати поступово. Утиліти інтероперабельності RxJS дозволяють сигналам та observables співіснувати.

typescript
// Before: RxJS-based
@Component({ ... })
export class LegacyComponent {
  private userSubject = new BehaviorSubject<User | null>(null);
  user$ = this.userSubject.asObservable();
  
  userName$ = this.user$.pipe(
    map(u => u?.name ?? 'Guest')
  );
}

// After: Signal-based
@Component({ ... })
export class ModernComponent {
  user = signal<User | null>(null);
  
  userName = computed(() => this.user()?.name ?? 'Guest');
}

Для асинхронних операцій слід зберегти RxJS для pipeline та конвертувати на межі:

typescript
// Hybrid approach
private search$ = toObservable(this.searchQuery).pipe(
  debounceTime(300),
  switchMap(q => this.api.search(q))
);

results = toSignal(this.search$, { initialValue: [] });

Починай практикувати!

Перевір свої знання з нашими симуляторами співбесід та технічними тестами.

Підготовка до Питань Співбесід про Angular Signals

  • Сигнали є синхронними примітивами; RxJS обробляє асинхронні потоки
  • Computed signals є ледачими та мемоізованими, перераховуються лише при зміні залежностей
  • Effects запускаються асинхронно під час виявлення змін, не одразу
  • linkedSignal вирішує проблему записуваного похідного стану без effects
  • Signal inputs (input(), model()) замінюють декоратори реактивними альтернативами
  • toSignal та toObservable з'єднують сигнали та RxJS для поступової міграції
  • Беззонний Angular покладається на сигнали для тонкого виявлення змін
  • Resource API (rxResource) декларативно керує асинхронним станом
  • Офіційний посібник Angular signals залишається авторитетним джерелом інформації про деталі API та найкращі практики
Щоденний виклик

Чи знайдеш ти помилку в Angular?

Справжній фрагмент коду, прихована помилка, одна спроба на день. Щоб спробувати, акаунт не потрібен.

Anthony Fillion-Maillet

Автор:

Anthony Fillion-Maillet

Засновник SharpSkill

Fullstack-розробник понад 10 років. Керує SharpSkill і відповідає за все, що тут публікується.

Оновлено 17 вересня 2026 р.

Поділитися

Пов'язані статті