Angular 18 Signals: Yeni Reaktif API'ler ve Zone.js'siz Değişiklik Algılama

Angular 18 Signals için kapsamlı rehber: input(), model(), viewChild(), zoneless mod. Kod örnekleriyle pratik geçiş kılavuzu.

Angular 18 Signals ve modern reaktivite görseli

Angular 18, Signals'in stabilize edilmesiyle framework'ün evriminde bir dönüm noktasına işaret ediyor. Bu yeni reaktif primitif, Angular bileşenlerinin nasıl oluşturulduğunu temelden değiştiriyor, geleneksel dekoratörlere modern bir alternatif sunuyor ve Zone.js'siz değişiklik algılamaya giden yolu açıyor.

Neler öğrenilecek

Angular 18'in sinyal tabanlı API'leri: input(), model(), viewChild() ve daha hafif, daha performanslı uygulamalar için zoneless yapılandırma.

Angular 18'de Signals'i Anlamak

Signals, Angular'da reaktiviteye yeni bir yaklaşım sunuyor. Zone.js değişiklik algılamasına dayanan @Input() gibi klasik dekoratörlerin aksine, Signals ince taneli ve açık reaktivite sağlıyor. Her Signal bir değeri kapsüller ve bu değer değiştiğinde tüketicileri otomatik olarak bilgilendirir.

Bu yaklaşım birçok avantaj getiriyor: hedeflenmiş güncellemeler sayesinde daha iyi performans, computed() ve effect() fonksiyonlarıyla doğal entegrasyon ve Angular'ın zoneless geleceğine hazırlık.

signals-basics.component.tstypescript
// Demonstration of fundamental Signal concepts
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 {
  // 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 reaktif konteynerler olarak çalışır: signal() yazılabilir bir Signal oluşturur, computed() hesaplanmış değerler türetir ve effect() değişikliklere yanıt olarak eylemler yürütmeye olanak tanır.

input() ile Signal Inputs

input() fonksiyonu geleneksel @Input() dekoratörünün yerini alır. Salt okunur bir InputSignal döndürerek verilerin her zaman üst bileşenden alt bileşene, yanlışlıkla değiştirilme riski olmadan aktığını garanti eder.

book-card.component.tstypescript
// 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: `
    <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 {
  // Required input - template won't compile without this prop
  book = input.required<Book>();

  // 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);
  });
}

Üst bileşen şablonunda kullanım benzer olmaya devam eder, ancak tip güvenliği ve Signal reaktivitesi ile:

book-list.component.tstypescript
// 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: `
    <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');
}

@Input() ile temel fark: signal inputs salt okunurdur. Alt bileşenden this.book.set() çağrısı yapılamaz, bu da tek yönlü veri akışını güçlendirir.

model() ile Çift Yönlü Bağlama

Çift yönlü senkronizasyon gerektiren durumlar için Angular 18 model() fonksiyonunu sunar. Bu fonksiyon, değişiklikleri otomatik olarak üst bileşene yayan yazılabilir bir Signal oluşturur.

search-input.component.tstypescript
// Component with bidirectional binding 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() creates a bidirectional Signal
  // Modifications propagate to parent
  query = model('');

  // Classic input for configuration
  placeholder = model('Search...');

  // Output for additional events
  searchSubmitted = output<string>();

  // 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());
    }
  }
}

Üst bileşen, çift yönlü bağlama için banana-in-a-box sözdizimi olan [()] kullanır:

app.component.tstypescript
// 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: `
    <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 {
  // 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()

Salt okunur veriler (üst → alt) için input() kullanılır. Alt bileşenin değeri değiştirmesi gerektiğinde (çift yönlü) model() tercih edilir.

viewChild() ve contentChild() ile Signal Queries

viewChild(), viewChildren(), contentChild() ve contentChildren() fonksiyonları karşılık gelen dekoratörlerin yerini alır. Signals döndürerek ngAfterViewInit gibi yaşam döngüsü hook'larına olan ihtiyacı ortadan kaldırır.

form-container.component.tstypescript
// 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: `
    <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 returns Signal<ElementRef | undefined>
  formElement = viewChild<ElementRef>('formElement');

  // viewChild.required guarantees element exists
  firstInput = viewChild.required<ElementRef<HTMLInputElement>>('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');
    }
  }
}

İçerik yansıtma ve erişim için contentChild() benzer şekilde çalışır:

card.component.tstypescript
// Using contentChild for projected content
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 {
  // Detect if footer was projected
  footerContent = contentChild<ElementRef>('[card-footer]');

  // Computed to check footer presence
  hasFooter = computed(() => this.footerContent() !== undefined);
}

Angular mülakatlarında başarılı olmaya hazır mısın?

İnteraktif simülatörler, flashcards ve teknik testlerle pratik yap.

Zone.js'siz Değişiklik Algılama (Zoneless)

Angular 18, deneysel modda Zone.js'siz değişiklik algılamayı sunuyor. Bu özellik, bundle boyutunu yaklaşık 13 KB azaltıyor ve tarayıcının asenkron API'lerine uygulanan monkey-patch'leri ortadan kaldırarak performansı artırıyor.

main.tstypescript
// 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 yapılandırmasının da Zone.js'yi kaldırmak için güncellenmesi gerekir:

json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
          }
        }
      }
    }
  }
}

Zoneless modda değişiklik algılama şu durumlarda otomatik olarak tetiklenir: Signal güncellemesi, markForCheck() çağrısı, AsyncPipe üzerinden yeni değer alımı veya bileşen ekleme/çıkarma.

zoneless-counter.component.tstypescript
// 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: `
    <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() {
    // 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 Uyumluluk

ChangeDetectionStrategy.OnPush ve Signals kullanan bileşenler genellikle zoneless modla uyumludur. Signal olmayan özelliklerin doğrudan değiştirilmesinden kaçınılmalıdır.

Mevcut Bileşenlerin Taşınması

Sinyal tabanlı API'lere geçiş kademeli olarak yapılabilir. Geleneksel bir bileşenin yeniden yapılandırma örneği:

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: `
    <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);
  }
}
typescript
// AFTER: Component migrated to 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 replaces @Input() with !
  user = input.required<User>();

  // viewChild.required replaces @ViewChild with !
  container = viewChild.required<ElementRef>('container');

  constructor() {
    // effect replaces ngAfterViewInit for queries
    effect(() => {
      console.log('Container ready:', this.container().nativeElement);
    });
  }
}

Bu taşımanın avantajları: daha sıkı tip kontrolü, otomatik reaktivite, daha az şablon kodu ve zoneless mod uyumluluğu.

Signals ile En İyi Uygulamalar

Angular 18'de Signals'den en iyi şekilde yararlanmak için temel öneriler:

best-practices.component.tstypescript
// Example of best practices with 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 for mutable data
  items = signal<Product[]>([]);

  // 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));
  }
}

Akılda tutulması gereken temel noktalar:

  • Türetilmiş değerler için şablonda yeniden hesaplama yerine computed() kullanılmalıdır
  • Yeni değer eskisine bağlı olduğunda set() yerine update() tercih edilmelidir
  • Effect'lerde döngüsel bağımlılıklardan kaçınmak için untracked() kullanılmalıdır
  • Render optimizasyonu için @for döngülerinde track her zaman belirtilmelidir

Sonuç

Angular 18, Signals aracılığıyla Zone.js'siz bir geleceğin temellerini atıyor. Temel çıkarımlar:

  • input(), daha sıkı tip kontrolü ve garantili salt okunur erişim ile @Input() dekoratörünün yerini alır
  • model(), üst ve alt bileşenler arasında reaktif çift yönlü bağlamayı mümkün kılar
  • viewChild() ve contentChild(), yaşam döngüsü hook'larına olan ihtiyacı ortadan kaldırır
  • Zoneless, bundle boyutunu azaltır ve performansı artırır
  • computed() ve effect(), reaktif ekosistemi tamamlar
  • Bileşen bileşen kademeli geçiş mümkündür

Signals'in benimsenmesi, Angular uygulamalarını zoneless modun norm haline geleceği gelecek sürümlere hazırlar. Bu dönüşüm, uzun vadeli bakım kolaylığı ve performans için akıllıca bir yatırımdır.

Pratik yapmaya başla!

Mülakat simülatörleri ve teknik testlerle bilgini test et.

Etiketler

#angular 18
#angular signals
#zoneless
#signal inputs
#reactivity

Paylaş

İlgili makaleler