# Angular 18 Signals: нові реактивні API та виявлення змін без Zone.js
> Повний посібник з Angular 18 Signals: input(), model(), viewChild(), zoneless режим. Практичний гід з прикладами коду та міграцією компонентів.
- Published: 2026-01-18
- Updated: 2026-04-10
- Author: SharpSkill
- Tags: angular 18, angular signals, zoneless, signal inputs, reactivity
- Reading time: 12 min
---
Angular 18 стає переломним моментом в еволюції фреймворку завдяки стабілізації Signals. Цей новий реактивний примітив фундаментально змінює підхід до побудови компонентів Angular, пропонуючи сучасну альтернативу традиційним декораторам та прокладаючи шлях до виявлення змін без Zone.js.
> **Що можна дізнатися**
>
> API на основі сигналів в Angular 18: input(), model(), viewChild() та конфігурація zoneless для легших і продуктивніших застосунків.
## Розуміння Signals в Angular 18
Signals представляють новий підхід до реактивності в Angular. На відміну від класичних декораторів на кшталт `@Input()`, які залежать від виявлення змін через Zone.js, Signals забезпечують точкову та явну реактивність. Кожен Signal інкапсулює значення та автоматично повідомляє споживачів при його зміні.
Такий підхід надає кілька переваг: краща продуктивність завдяки цілеспрямованим оновленням, нативна інтеграція з функціями `computed()` та `effect()`, а також підготовка до майбутнього Angular без Zone.js.
```typescript
// signals-basics.component.ts
// Demonstration of fundamental Signal concepts
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
Counter: {{ count() }}
Double: {{ doubleCount() }}
`
})
export class CounterComponent {
// Writable signal - value can be modified
count = signal(0);
// Computed signal - automatically derived from count
// Only recalculates when count changes
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - executed on every count change
// Useful for side effects (logs, API calls, etc.)
effect(() => {
console.log(`New counter value: ${this.count()}`);
});
}
increment() {
// update() allows modification based on previous value
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() directly replaces the value
this.count.set(0);
}
}
```
Signals працюють як реактивні контейнери: `signal()` створює записуваний Signal, `computed()` виводить обчислювані значення, а `effect()` дозволяє виконувати дії у відповідь на зміни.
## Signal Inputs з input()
Функція `input()` замінює традиційний декоратор `@Input()`. Вона повертає `InputSignal` тільки для читання, гарантуючи, що дані завжди передаються від батьківського до дочірнього компонента без випадкових модифікацій.
```typescript
// book-card.component.ts
// Component using signal inputs
import { Component, input, computed } from '@angular/core';
interface Book {
id: string;
title: string;
author: string;
price: number;
discountPercent?: number;
}
@Component({
selector: 'app-book-card',
standalone: true,
template: `
{{ book().title }}
By {{ book().author }}
@if (hasDiscount()) {
\${{ book().price }}\${{ discountedPrice() }}
} @else {
\${{ book().price }}
}
@if (featured()) {
Featured
}
`
})
export class BookCardComponent {
// Required input - template won't compile without this prop
book = input.required();
// Optional input with default value
featured = input(false);
// Computed based on input - automatically recalculated
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// Discounted price calculation
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}
```
Використання в шаблоні батьківського компонента залишається подібним, але з типобезпекою та реактивністю Signal:
```typescript
// book-list.component.ts
// Parent component using book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
@for (book of books(); track book.id) {
}
`
})
export class BookListComponent {
books = signal([
{ id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 },
{ id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 }
]);
featuredBookId = signal('1');
}
```
Ключова відмінність від `@Input()`: signal inputs доступні тільки для читання. Виклик `this.book.set()` з дочірнього компонента неможливий, що посилює односпрямований потік даних.
## Двостороннє зв'язування з model()
Для випадків, що потребують двосторонньої синхронізації, Angular 18 вводить `model()`. Ця функція створює записуваний Signal, який автоматично поширює зміни до батьківського компонента.
```typescript
// search-input.component.ts
// Component with bidirectional binding via model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
`
})
export class SearchInputComponent {
// model() creates a bidirectional Signal
// Modifications propagate to parent
query = model('');
// Classic input for configuration
placeholder = model('Search...');
// Output for additional events
searchSubmitted = output();
// Computed based on model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// Update model - propagates to parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}
```
Батьківський компонент використовує синтаксис banana-in-a-box `[()]` для двостороннього зв'язування:
```typescript
// app.component.ts
// Using two-way binding with model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
Current search: {{ searchTerm() }}
@for (result of filteredResults(); track result.id) {
{{ result.name }}
}
`
})
export class AppComponent {
// Local signal synchronized with child component
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// Reactive filtering based on searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}
```
> **model() vs input()**
>
> `input()` використовується для даних тільки для читання (батько → дочірній). `model()` застосовується, коли дочірній компонент має модифікувати значення (двосторонньо).
## Signal Queries з viewChild() та contentChild()
Функції `viewChild()`, `viewChildren()`, `contentChild()` та `contentChildren()` замінюють відповідні декоратори. Вони повертають Signals, усуваючи потребу в хуках життєвого циклу на кшталт `ngAfterViewInit`.
```typescript
// form-container.component.ts
// Demonstration of signal queries
import {
Component,
viewChild,
viewChildren,
ElementRef,
effect,
signal
} from '@angular/core';
import { FormFieldComponent } from './form-field.component';
@Component({
selector: 'app-form-container',
standalone: true,
imports: [FormFieldComponent],
template: `
`
})
export class FormContainerComponent {
// viewChild returns Signal
formElement = viewChild('formElement');
// viewChild.required guarantees element exists
firstInput = viewChild.required>('firstInput');
// Query on a component - returns the component itself
firstFormField = viewChild(FormFieldComponent);
// viewChildren for multiple elements
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect replaces ngAfterViewInit for queries
effect(() => {
// Signal is automatically resolved
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// React to list changes
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// Direct access via Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// Access the form
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}
```
Для проєкції контенту та доступу до нього `contentChild()` працює аналогічно:
```typescript
// card.component.ts
// Using contentChild for projected content
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
@if (hasFooter()) {
}
`
})
export class CardComponent {
// Detect if footer was projected
footerContent = contentChild('[card-footer]');
// Computed to check footer presence
hasFooter = computed(() => this.footerContent() !== undefined);
}
```
## Виявлення змін без Zone.js (Zoneless)
Angular 18 вводить виявлення змін без Zone.js в експериментальному режимі. Ця функціональність зменшує розмір бандла приблизно на 13 КБ та покращує продуктивність, усуваючи monkey-патчі на асинхронних API браузера.
```typescript
// main.ts
// Configuring the application in zoneless mode
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// Enable experimental zoneless detection
provideExperimentalZonelessChangeDetection()
]
});
```
Конфігурацію `angular.json` також необхідно оновити для видалення Zone.js:
```json
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}
```
У режимі zoneless виявлення змін спрацьовує автоматично в таких випадках: оновлення Signal, виклик `markForCheck()`, отримання нового значення через AsyncPipe або підключення/відключення компонента.
```typescript
// zoneless-counter.component.ts
// Component optimized for zoneless mode
import {
Component,
signal,
ChangeDetectionStrategy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-zoneless-counter',
standalone: true,
// OnPush recommended for zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
Counter: {{ count() }}
@if (loading()) {
Loading...
}
@if (data()) {
{{ data() | json }}
}
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal(null);
increment() {
// Signal update triggers detection
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals guarantee view updates
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}
```
> **Сумісність з Zoneless**
>
> Компоненти, що використовують `ChangeDetectionStrategy.OnPush` та Signals, загалом сумісні з режимом zoneless. Слід уникати прямих модифікацій властивостей, які не є Signals.
## Міграція існуючих компонентів
Міграція на API, засновані на сигналах, може відбуватися поступово. Нижче наведено приклад рефакторингу традиційного компонента:
```typescript
// BEFORE: Component with classic decorators
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
`
})
export class CartComponent {
// Signal for mutable data
items = signal([]);
// Computed for derived values - avoids unnecessary recalculations
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect for side effects (analytics, persistence)
effect(() => {
const currentItems = this.items();
// untracked avoids creating a dependency
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() for modifications based on previous state
this.items.update(current => {
const existing = current.find(i => i.id === product.id);
if (existing) {
return current.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...current, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this.items.update(current => current.filter(i => i.id !== id));
}
}
```
Ключові принципи:
- Для похідних значень слід використовувати `computed()` замість перерахунку в шаблоні
- `update()` є кращим варіантом ніж `set()`, коли нове значення залежить від попереднього
- `untracked()` в ефектах запобігає циклічним залежностям
- Атрибут `track` в циклах `@for` обов'язковий для оптимізації рендерингу
## Висновок
Angular 18 закладає фундамент для майбутнього без Zone.js завдяки Signals. Ключові висновки:
- **input()** замінює `@Input()` з суворішою типізацією та гарантованим доступом тільки для читання
- **model()** забезпечує реактивне двостороннє зв'язування між батьківським та дочірнім компонентами
- **viewChild()** та **contentChild()** усувають потребу в хуках життєвого циклу
- **Zoneless** зменшує розмір бандла та покращує продуктивність
- **computed()** та **effect()** доповнюють реактивну екосистему
- Поступова міграція можлива компонент за компонентом
Впровадження Signals готує застосунки Angular до майбутніх версій, де режим zoneless стане нормою. Ця трансформація є розумною інвестицією в довгострокову підтримуваність та продуктивність.
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/uk/blog/angular/angular-18-signals-new-features