Angular 18: Signals dan Fitur-Fitur Baru
Pelajari Angular 18 Signals, deteksi perubahan zoneless, dan API berbasis signal baru untuk membangun aplikasi yang lebih performan.

Angular 18 menandai titik balik dalam evolusi framework ini dengan stabilisasi Signals. Primitif reaktif baru ini secara fundamental mengubah cara membangun komponen Angular, menawarkan alternatif modern dari dekorator tradisional sekaligus membuka jalan menuju deteksi perubahan tanpa Zone.js.
API berbasis signal di Angular 18: input(), model(), viewChild(), dan konfigurasi zoneless untuk aplikasi yang lebih ringan dan performan.
Memahami Signals di Angular 18
Signals merepresentasikan pendekatan baru terhadap reaktivitas di Angular. Berbeda dengan dekorator klasik seperti @Input() yang bergantung pada deteksi perubahan Zone.js, Signals menawarkan reaktivitas yang presisi dan eksplisit. Setiap Signal mengenkapsulasi sebuah nilai dan secara otomatis memberi notifikasi kepada konsumen saat nilai tersebut berubah.
Pendekatan ini memberikan beberapa keuntungan: performa lebih baik melalui pembaruan yang ditargetkan, integrasi native dengan fungsi computed() dan effect(), serta persiapan menuju masa depan zoneless Angular.
// Demonstrasi konsep fundamental Signals
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<div class="counter-container">
<h2>Counter: {{ count() }}</h2>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
<button (click)="reset()">Reset</button>
</div>
`
})
export class CounterComponent {
// Signal writable - nilai bisa dimodifikasi
count = signal(0);
// Signal computed - diturunkan secara otomatis dari count
// Hanya dihitung ulang saat count berubah
doubleCount = computed(() => this.count() * 2);
constructor() {
// Effect - dieksekusi setiap kali count berubah
// Berguna untuk side effects (log, API call, dll.)
effect(() => {
console.log(`Nilai counter baru: ${this.count()}`);
});
}
increment() {
// update() memungkinkan modifikasi berdasarkan nilai sebelumnya
this.count.update(value => value + 1);
}
decrement() {
this.count.update(value => value - 1);
}
reset() {
// set() langsung mengganti nilai
this.count.set(0);
}
}Signals berfungsi sebagai kontainer reaktif: signal() membuat Signal yang dapat ditulis, computed() menurunkan nilai-nilai yang dihitung, dan effect() memungkinkan eksekusi aksi sebagai respons terhadap perubahan.
Signal Inputs dengan input()
Fungsi input() menggantikan dekorator @Input() tradisional. Fungsi ini mengembalikan InputSignal yang hanya-baca, memastikan data selalu mengalir dari parent ke child tanpa modifikasi yang tidak disengaja.
// Komponen menggunakan 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: `
<article class="book-card">
<h3>{{ book().title }}</h3>
<p class="author">By {{ book().author }}</p>
@if (hasDiscount()) {
<p class="price">
<span class="original">\${{ book().price }}</span>
<span class="discounted">\${{ discountedPrice() }}</span>
</p>
} @else {
<p class="price">\${{ book().price }}</p>
}
@if (featured()) {
<span class="badge">Featured</span>
}
</article>
`
})
export class BookCardComponent {
// Input wajib - template tidak bisa dikompilasi tanpa prop ini
book = input.required<Book>();
// Input opsional dengan nilai default
featured = input(false);
// Computed berdasarkan input - dihitung ulang secara otomatis
hasDiscount = computed(() => {
const discount = this.book().discountPercent;
return discount !== undefined && discount > 0;
});
// Perhitungan harga diskon
discountedPrice = computed(() => {
const { price, discountPercent } = this.book();
if (!discountPercent) return price;
return (price * (100 - discountPercent) / 100).toFixed(2);
});
}Penggunaan di template parent tetap serupa, namun dengan keamanan tipe dan reaktivitas Signal:
// Komponen parent menggunakan book-card
import { Component, signal } from '@angular/core';
import { BookCardComponent } from './book-card.component';
@Component({
selector: 'app-book-list',
standalone: true,
imports: [BookCardComponent],
template: `
<div class="book-grid">
@for (book of books(); track book.id) {
<app-book-card
[book]="book"
[featured]="book.id === featuredBookId()"
/>
}
</div>
`
})
export class BookListComponent {
books = signal<Book[]>([
{ 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');
}Perbedaan utama dengan @Input(): signal inputs bersifat hanya-baca. Memanggil this.book.set() dari komponen child tidak dimungkinkan, yang memperkuat aliran data searah.
Two-Way Binding dengan model()
Untuk kasus yang memerlukan sinkronisasi dua arah, Angular 18 memperkenalkan model(). Fungsi ini membuat Signal yang dapat ditulis dan secara otomatis mempropagasikan perubahan ke komponen parent.
// Komponen dengan binding dua arah via model()
import { Component, model, output, computed } from '@angular/core';
@Component({
selector: 'app-search-input',
standalone: true,
template: `
<div class="search-container">
<input
type="text"
[value]="query()"
(input)="onInput($event)"
[placeholder]="placeholder()"
class="search-input"
/>
@if (query().length > 0) {
<button (click)="clear()" class="clear-btn">×</button>
}
<span class="char-count">{{ charCount() }} characters</span>
</div>
`
})
export class SearchInputComponent {
// model() membuat Signal dua arah
// Modifikasi dipropagasikan ke parent
query = model('');
// Input klasik untuk konfigurasi
placeholder = model('Search...');
// Output untuk event tambahan
searchSubmitted = output<string>();
// Computed berdasarkan model
charCount = computed(() => this.query().length);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
// Update model - dipropagasikan ke parent
this.query.set(value);
}
clear() {
this.query.set('');
}
submit() {
if (this.query().length > 0) {
this.searchSubmitted.emit(this.query());
}
}
}Parent menggunakan sintaks banana-in-a-box [()] untuk binding dua arah:
// Penggunaan two-way binding dengan model()
import { Component, signal, effect } from '@angular/core';
import { SearchInputComponent } from './search-input.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [SearchInputComponent],
template: `
<div class="app-container">
<app-search-input [(query)]="searchTerm" />
<p>Current search: {{ searchTerm() }}</p>
<div class="results">
@for (result of filteredResults(); track result.id) {
<div class="result-item">{{ result.name }}</div>
}
</div>
</div>
`
})
export class AppComponent {
// Signal lokal yang disinkronkan dengan komponen child
searchTerm = signal('');
results = signal([
{ id: 1, name: 'Angular 18' },
{ id: 2, name: 'React 19' },
{ id: 3, name: 'Vue 3' }
]);
// Filtering reaktif berdasarkan searchTerm
filteredResults = computed(() => {
const term = this.searchTerm().toLowerCase();
if (!term) return this.results();
return this.results().filter(r =>
r.name.toLowerCase().includes(term)
);
});
}Gunakan input() untuk data hanya-baca (parent ke child). Gunakan model() saat komponen child perlu memodifikasi nilai (dua arah).
Signal Queries dengan viewChild() dan contentChild()
Fungsi viewChild(), viewChildren(), contentChild(), dan contentChildren() menggantikan dekorator yang sesuai. Fungsi-fungsi ini mengembalikan Signals, menghilangkan kebutuhan lifecycle hooks seperti ngAfterViewInit.
// Demonstrasi 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: `
<form #formElement (submit)="onSubmit($event)">
<input #firstInput type="text" placeholder="Name" />
<app-form-field label="Email" />
<app-form-field label="Phone" />
<div class="actions">
<button type="submit">Submit</button>
<button type="button" (click)="focusFirst()">Focus first field</button>
</div>
</form>
`
})
export class FormContainerComponent {
// viewChild mengembalikan Signal<ElementRef | undefined>
formElement = viewChild<ElementRef>('formElement');
// viewChild.required menjamin elemen tersedia
firstInput = viewChild.required<ElementRef<HTMLInputElement>>('firstInput');
// Query pada komponen - mengembalikan komponen itu sendiri
firstFormField = viewChild(FormFieldComponent);
// viewChildren untuk beberapa elemen
allFormFields = viewChildren(FormFieldComponent);
constructor() {
// Effect menggantikan ngAfterViewInit untuk queries
effect(() => {
// Signal secara otomatis di-resolve
const input = this.firstInput();
console.log('First input available:', input.nativeElement);
});
// Bereaksi terhadap perubahan list
effect(() => {
const fields = this.allFormFields();
console.log(`${fields.length} form fields found`);
});
}
focusFirst() {
// Akses langsung via Signal
this.firstInput().nativeElement.focus();
}
onSubmit(event: Event) {
event.preventDefault();
// Akses formulir
const form = this.formElement();
if (form) {
console.log('Form submitted');
}
}
}Untuk memproyeksikan konten dan mengaksesnya, contentChild() bekerja dengan cara serupa:
// Menggunakan contentChild untuk konten yang diproyeksikan
import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
template: `
<div class="card">
<header class="card-header">
<ng-content select="[card-title]" />
</header>
<div class="card-body">
<ng-content />
</div>
@if (hasFooter()) {
<footer class="card-footer">
<ng-content select="[card-footer]" />
</footer>
}
</div>
`
})
export class CardComponent {
// Mendeteksi apakah footer diproyeksikan
footerContent = contentChild<ElementRef>('[card-footer]');
// Computed untuk memeriksa keberadaan footer
hasFooter = computed(() => this.footerContent() !== undefined);
}Siap menguasai wawancara Angular Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Deteksi Perubahan Zoneless
Angular 18 memperkenalkan deteksi perubahan tanpa Zone.js dalam mode eksperimental. Fitur ini mengurangi ukuran bundle sekitar 13 KB dan meningkatkan performa dengan menghilangkan monkey-patch pada API asinkron browser.
// Mengkonfigurasi aplikasi dalam mode zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
// Mengaktifkan deteksi zoneless eksperimental
provideExperimentalZonelessChangeDetection()
]
});Konfigurasi angular.json juga perlu diperbarui untuk menghapus Zone.js:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": []
}
}
}
}
}
}Dalam mode zoneless, deteksi perubahan dipicu secara otomatis dalam kasus berikut: pembaruan Signal, pemanggilan markForCheck(), nilai baru diterima melalui AsyncPipe, atau attach/detach komponen.
// Komponen yang dioptimalkan untuk mode zoneless
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 direkomendasikan untuk zoneless
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="counter">
<p>Counter: {{ count() }}</p>
<button (click)="increment()">Increment</button>
@if (loading()) {
<p>Loading...</p>
}
@if (data()) {
<pre>{{ data() | json }}</pre>
}
</div>
`
})
export class ZonelessCounterComponent {
private http = inject(HttpClient);
count = signal(0);
loading = signal(false);
data = signal<any>(null);
increment() {
// Pembaruan Signal memicu deteksi
this.count.update(c => c + 1);
}
async fetchData() {
this.loading.set(true);
try {
// Signals menjamin pembaruan tampilan
const response = await fetch('/api/data');
const json = await response.json();
this.data.set(json);
} finally {
this.loading.set(false);
}
}
}Komponen yang menggunakan ChangeDetectionStrategy.OnPush dan Signals umumnya kompatibel dengan mode zoneless. Hindari modifikasi langsung pada properti yang bukan Signal.
Migrasi Komponen yang Ada
Migrasi ke API berbasis signal dapat dilakukan secara bertahap. Berikut contoh refactoring komponen tradisional:
// SEBELUM: Komponen dengan dekorator klasik
// user-profile-legacy.component.ts
import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-user-profile-legacy',
template: `
<div #container>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
`
})
export class UserProfileLegacyComponent implements AfterViewInit {
@Input() user!: { name: string; email: string };
@ViewChild('container') container!: ElementRef;
ngAfterViewInit() {
console.log('Container ready:', this.container.nativeElement);
}
}// SESUDAH: Komponen yang dimigrasikan ke Signals
// user-profile.component.ts
import { Component, input, viewChild, ElementRef, effect } from '@angular/core';
interface User {
name: string;
email: string;
}
@Component({
selector: 'app-user-profile',
standalone: true,
template: `
<div #container>
<h2>{{ user().name }}</h2>
<p>{{ user().email }}</p>
</div>
`
})
export class UserProfileComponent {
// input.required menggantikan @Input() dengan !
user = input.required<User>();
// viewChild.required menggantikan @ViewChild dengan !
container = viewChild.required<ElementRef>('container');
constructor() {
// effect menggantikan ngAfterViewInit untuk queries
effect(() => {
console.log('Container ready:', this.container().nativeElement);
});
}
}Manfaat migrasi ini: typing yang lebih ketat, reaktivitas otomatis, kode boilerplate yang lebih sedikit, dan kompatibilitas dengan mode zoneless.
Praktik Terbaik dengan Signals
Berikut rekomendasi utama untuk memaksimalkan penggunaan Signals di Angular 18:
// Contoh praktik terbaik dengan Signals
import {
Component,
signal,
computed,
effect,
untracked,
ChangeDetectionStrategy
} from '@angular/core';
interface Product {
id: string;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="cart">
<h2>Cart ({{ itemCount() }} items)</h2>
@for (item of items(); track item.id) {
<div class="cart-item">
<span>{{ item.name }}</span>
<span>{{ item.quantity }} × \${{ item.price }}</span>
<button (click)="removeItem(item.id)">Remove</button>
</div>
}
<div class="cart-total">
<strong>Total: \${{ total() }}</strong>
</div>
</div>
`
})
export class CartComponent {
// Signal untuk data yang mutable
items = signal<Product[]>([]);
// Computed untuk nilai turunan - menghindari perhitungan ulang yang tidak perlu
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
constructor() {
// Effect untuk side effects (analytics, persistence)
effect(() => {
const currentItems = this.items();
// untracked menghindari pembuatan dependensi
untracked(() => {
localStorage.setItem('cart', JSON.stringify(currentItems));
});
});
}
addItem(product: Product) {
// update() untuk modifikasi berdasarkan state sebelumnya
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));
}
}Poin-poin utama yang perlu diingat:
- Gunakan
computed()untuk nilai turunan daripada menghitung ulang di template - Pilih
update()daripadaset()saat nilai baru bergantung pada nilai lama - Gunakan
untracked()dalam effects untuk menghindari dependensi melingkar - Selalu tentukan
trackdalam loop@foruntuk mengoptimalkan rendering
Kesimpulan
Angular 18 meletakkan fondasi untuk masa depan tanpa Zone.js melalui Signals. Poin-poin utama:
- input() menggantikan
@Input()dengan typing yang lebih ketat dan akses hanya-baca yang terjamin - model() memungkinkan two-way binding reaktif antara parent dan child
- viewChild() dan contentChild() menghilangkan kebutuhan lifecycle hooks
- Zoneless mengurangi ukuran bundle dan meningkatkan performa
- computed() dan effect() melengkapi ekosistem reaktif
- Migrasi bertahap dapat dilakukan komponen per komponen
Mengadopsi Signals mempersiapkan aplikasi Angular untuk versi mendatang di mana mode zoneless akan menjadi standar. Transisi ini merupakan investasi yang bijak untuk maintainability dan performa jangka panjang.
Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tag
Bagikan
