Angular Signals - Pytania Rekrutacyjne i Kompletny Przewodnik 2025

Angular Signals to największa zmiana w Angular od lat - nowy prymityw reaktywności, który zmienia sposób myślenia o state management i change detection. Na rozmowach rekrutacyjnych w 2025/2026 znajomość Signals jest obowiązkowa - pytania padają o różnice z RxJS, zoneless mode, i praktyczne wzorce użycia.

Odpowiedź w 30 sekund

Czym są Angular Signals?

Signals to reaktywny wrapper na wartość - gdy wartość się zmienia, wszyscy "konsumenci" (template, computed, effect) są automatycznie powiadamiani. Trzy główne API: signal() tworzy writable signal, computed() tworzy derived value który automatycznie się aktualizuje, effect() wykonuje side effects przy zmianach. Signals umożliwiają fine-grained change detection bez zone.js.

Odpowiedź w 2 minuty

Angular Signals zostały wprowadzone w wersji 16 (developer preview) i są stabilne od wersji 17. Rozwiązują kilka problemów klasycznego Angular:

Zone.js patchuje wszystkie async operacje (setTimeout, Promise, addEventListener) żeby wiedzieć kiedy uruchomić change detection. To działa, ale jest nieefektywne - Angular sprawdza cały komponent tree nawet gdy zmienił się jeden input. Signals zmieniają to fundamentalnie - Angular wie dokładnie która wartość się zmieniła i może zaktualizować tylko te elementy DOM które od niej zależą.

RxJS jest potężny ale ma stromą krzywą uczenia i wymaga manualnego zarządzania subskrypcjami. Dla prostego state managementu (counter, form state, UI flags) to overkill. Signals oferują prostszą alternatywę dla synchronicznego state, podczas gdy RxJS pozostaje lepszy dla async streams i complex operations.

Trzy podstawowe API:

signal<T>(initialValue) - tworzy WritableSignal. Odczytujesz wartość wywołując signal jako funkcję (count()), aktualizujesz przez .set(), .update(), lub .mutate().

computed(() => expression) - tworzy derived signal który automatycznie śledzi zależności. Gdy którykolwiek signal w expression się zmieni, computed przelicza wartość. Jest memoizowany - przelicza tylko gdy zależności się zmienią.

effect(() => sideEffect) - wykonuje kod gdy zależne signals się zmienią. Używany do logowania, synchronizacji z localStorage, lub integracji z zewnętrznymi bibliotekami.

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

@Component({
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);

  constructor() {
    // Loguj każdą zmianę
    effect(() => {
      console.log('Count changed to:', this.count());
    });
  }

  increment() {
    this.count.update(c => c + 1);
  }
}

Podstawy Signals

Czym są Signals i dlaczego zostały wprowadzone?

Signals to prymityw reaktywności - wrapper na wartość, który powiadamia konsumentów (template, computed, effect) gdy wartość się zmienia. Angular śledzi które signals są używane gdzie, i aktualizuje tylko te części aplikacji które zależą od zmienionych signals.

Dlaczego wprowadzono Signals:

  1. Fine-grained reactivity - zamiast sprawdzać cały komponent, Angular aktualizuje konkretne elementy DOM
  2. Zoneless mode - eliminacja zone.js (mniejszy bundle, lepsza performance)
  3. Prostszy mental model - łatwiejsze niż RxJS dla synchronicznego state
  4. Lepsze DevTools - łatwiejsze debugowanie (wartość jest zawsze dostępna)
  5. Glitch-free - computed i effects aktualizują się po wszystkich zmianach, nie w trakcie
// Stary sposób z zone.js
@Component({...})
export class OldComponent {
  count = 0; // Angular nie wie kiedy się zmienia

  increment() {
    this.count++; // zone.js łapie to przez patching
  }
}

// Nowy sposób z Signals
@Component({...})
export class NewComponent {
  count = signal(0); // Angular wie dokładnie kiedy się zmienia

  increment() {
    this.count.update(c => c + 1); // Explicit notification
  }
}

Jak działa signal() - tworzenie i aktualizacja?

signal<T>(initialValue) tworzy WritableSignal - reaktywny kontener na wartość:

import { signal } from '@angular/core';

// Tworzenie
const count = signal(0);
const user = signal<User | null>(null);
const items = signal<string[]>([]);

// Odczyt - wywołaj jako funkcję
console.log(count()); // 0
console.log(user()); // null

// Aktualizacja - trzy metody

// set() - zastąp wartość
count.set(5);
user.set({ name: 'John', age: 30 });

// update() - bazuj na poprzedniej wartości
count.update(c => c + 1);
items.update(arr => [...arr, 'new item']);

// mutate() - zmutuj obiekt/tablicę (deprecated w v18+, używaj update)
// items.mutate(arr => arr.push('item')); // NIE UŻYWAJ

// asReadonly() - zwróć read-only wersję
const readonlyCount: Signal<number> = count.asReadonly();
// readonlyCount.set(5); // Error - brak set/update na Signal

Jak działa computed() - derived values?

computed() tworzy signal którego wartość jest automatycznie obliczana na podstawie innych signals:

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

const firstName = signal('John');
const lastName = signal('Doe');

// Computed automatycznie śledzi zależności
const fullName = computed(() => `${firstName()} ${lastName()}`);

console.log(fullName()); // "John Doe"

firstName.set('Jane');
console.log(fullName()); // "Jane Doe" - automatycznie przeliczone

// Computed jest memoizowany
const expensiveComputed = computed(() => {
  console.log('Computing...'); // Wywoła się tylko gdy zależności się zmienią
  return someExpensiveCalculation(data());
});

// Wielokrotny odczyt nie przelicza
expensiveComputed(); // "Computing..." (pierwsze wywołanie)
expensiveComputed(); // (brak loga - cached)
expensiveComputed(); // (brak loga - cached)

// Nested computed
const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
const doubled = computed(() => sum() * 2);

a.set(5);
console.log(doubled()); // 14 - (5 + 2) * 2

Ważne: computed jest lazy - nie oblicza wartości dopóki ktoś jej nie odczyta:

const data = signal([1, 2, 3]);

// Ten computed nie wykona się dopóki ktoś go nie odczyta
const processed = computed(() => {
  console.log('Processing...');
  return data().map(x => x * 2);
});

// Dopiero tutaj się wykona
console.log(processed()); // "Processing..." → [2, 4, 6]

Jak działa effect() - side effects?

effect() wykonuje callback gdy którykolwiek z odczytanych signals się zmieni:

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

const count = signal(0);

// Effect wykonuje się natychmiast i przy każdej zmianie
effect(() => {
  console.log('Count is now:', count());
});
// Logi: "Count is now: 0"

count.set(1); // Logi: "Count is now: 1"
count.set(2); // Logi: "Count is now: 2"

Typowe use cases dla effect:

@Component({...})
export class MyComponent {
  theme = signal<'light' | 'dark'>('light');
  searchQuery = signal('');

  constructor() {
    // Sync z localStorage
    effect(() => {
      localStorage.setItem('theme', this.theme());
    });

    // Sync z external library
    effect(() => {
      this.chartLibrary.setTheme(this.theme());
    });

    // Logging/Analytics
    effect(() => {
      analytics.track('search', { query: this.searchQuery() });
    });

    // DOM manipulation (rzadko potrzebne)
    effect(() => {
      document.body.classList.toggle('dark', this.theme() === 'dark');
    });
  }
}

Cleanup w effect:

effect((onCleanup) => {
  const subscription = someObservable$.subscribe(value => {
    mySignal.set(value);
  });

  // Cleanup przed następnym wykonaniem lub przy destroy
  onCleanup(() => {
    subscription.unsubscribe();
  });
});

Effect options:

// allowSignalWrites - pozwól na set/update w effect (domyślnie false)
effect(() => {
  if (this.source() > 100) {
    this.derived.set(100); // Error bez allowSignalWrites!
  }
}, { allowSignalWrites: true });

// injector - custom injector dla effect poza injection context
effect(() => {...}, { injector: this.injector });

// manualCleanup - nie niszcz automatycznie przy destroy komponentu
const effectRef = effect(() => {...}, { manualCleanup: true });
effectRef.destroy(); // Manualne zniszczenie

Czym różni się WritableSignal od Signal?

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

// WritableSignal - możesz odczytywać i zapisywać
const count: WritableSignal<number> = signal(0);
count.set(5);      // ✅
count.update(c => c + 1); // ✅
count();           // ✅ odczyt

// Signal (readonly) - tylko odczyt
const double: Signal<number> = computed(() => count() * 2);
// double.set(10);  // ❌ Error - Signal nie ma set/update
double();          // ✅ odczyt

// Konwersja WritableSignal → Signal
const readonlyCount: Signal<number> = count.asReadonly();
// readonlyCount.set(5); // ❌ Error

// Typowe użycie - expose readonly w serwisie
@Injectable({ providedIn: 'root' })
export class UserService {
  private _user = signal<User | null>(null);

  // Publiczny readonly signal
  readonly user: Signal<User | null> = this._user.asReadonly();

  // Tylko serwis może modyfikować
  login(user: User) {
    this._user.set(user);
  }

  logout() {
    this._user.set(null);
  }
}

Signals vs RxJS

Kiedy używać Signals a kiedy RxJS Observables?

Aspekt Signals RxJS Observables
Wartość Zawsze ma wartość (synchronous) Może nie mieć wartości (lazy)
Execution Eager (od razu) Lazy (przy subscribe)
Dependencies Automatyczne śledzenie Explicit (pipe, operators)
Async Nie natywnie Tak, core feature
Cancellation Brak Unsubscribe
Operators Brak (computed) 100+ operatorów
Learning curve Niska Wysoka

Używaj Signals dla:

  • Synchroniczny state (counters, flags, form values)
  • Derived/computed values
  • UI state (isLoading, isOpen, selectedItem)
  • Simple component state

Używaj RxJS dla:

  • HTTP requests
  • WebSockets, Server-Sent Events
  • Event streams (debounce, throttle, distinctUntilChanged)
  • Complex async flows (retry, timeout, race)
  • Multicasting (share, shareReplay)
@Component({...})
export class HybridComponent {
  // Signals - synchronous state
  count = signal(0);
  isLoading = signal(false);
  selectedId = signal<number | null>(null);

  // Computed - derived state
  isValid = computed(() => this.count() > 0 && this.count() < 100);

  // RxJS - async operations
  users$ = this.http.get<User[]>('/api/users');

  // RxJS - complex event handling
  search$ = this.searchInput.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(query => this.http.get(`/api/search?q=${query}`))
  );

  constructor(private http: HttpClient) {}
}

Jak konwertować między Signals a Observables?

Angular dostarcza funkcje do interoperability:

import { signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

// Signal → Observable
const count = signal(0);
const count$ = toObservable(count);

count$.subscribe(value => console.log('Count:', value));
count.set(1); // Logi: "Count: 1"

// Observable → Signal
const timer$ = interval(1000);
const timerSignal = toSignal(timer$, { initialValue: 0 });

// W template
// {{ timerSignal() }}

// toSignal options
const data = toSignal(this.http.get('/api/data'), {
  initialValue: [],           // Wartość przed pierwszym emit
  // lub
  requireSync: true,          // Error jeśli Observable nie emituje synchronicznie
  // lub
  manualCleanup: true,        // Nie unsubscribe automatycznie
});

// Obsługa błędów
const dataWithError = toSignal(this.http.get('/api/data').pipe(
  catchError(err => of({ error: err.message }))
), { initialValue: null });

Ważne uwagi:

// toObservable wymaga injection context
@Component({...})
export class MyComponent {
  count = signal(0);
  count$ = toObservable(this.count); // ✅ W class field

  ngOnInit() {
    // ❌ Error - poza injection context
    // const count$ = toObservable(this.count);

    // ✅ Z explicit injector
    const count$ = toObservable(this.count, { injector: this.injector });
  }
}

// toSignal automatycznie unsubscribuje przy destroy komponentu
// Nie musisz manualnie zarządzać subskrypcją

Czy Signals zastąpią RxJS?

Nie - Signals i RxJS są komplementarne, nie konkurencyjne:

Signals nie zastąpią RxJS bo:

  • Brak operatorów (debounce, switchMap, retry)
  • Brak natywnej obsługi async
  • Brak cancellation
  • Synchronous-only

RxJS pozostaje dla:

  • HTTP (HttpClient zwraca Observable)
  • Router events
  • Form valueChanges
  • WebSockets
  • Complex event processing

Przyszłość Angular:

  • Signals dla state management
  • RxJS dla async operations
  • toSignal()/toObservable() jako most
// Typowy pattern w 2025+
@Component({...})
export class ModernComponent {
  private http = inject(HttpClient);
  private route = inject(ActivatedRoute);

  // Route param jako Signal
  id = toSignal(this.route.paramMap.pipe(
    map(params => params.get('id'))
  ));

  // HTTP data jako Signal
  user = toSignal(
    toObservable(this.id).pipe(
      filter(Boolean),
      switchMap(id => this.http.get<User>(`/api/users/${id}`))
    ),
    { initialValue: null }
  );

  // UI state jako Signals
  isEditing = signal(false);
  formData = signal<Partial<User>>({});

  // Derived
  canSave = computed(() =>
    this.isEditing() &&
    this.formData().name?.length > 0
  );
}

Signal-based APIs

Jak działają signal inputs (input())?

Od Angular 17.1 możesz używać signal-based inputs zamiast @Input():

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

@Component({
  selector: 'app-user-card',
  template: `
    <div class="card">
      <h2>{{ name() }}</h2>
      <p>Age: {{ age() }}</p>
      <p *ngIf="email()">Email: {{ email() }}</p>
    </div>
  `
})
export class UserCardComponent {
  // Required input
  name = input.required<string>();

  // Optional input z domyślną wartością
  age = input(0);

  // Optional input bez domyślnej wartości
  email = input<string>();

  // Input z aliasem
  userId = input.required<number>({ alias: 'id' });

  // Input z transform
  disabled = input(false, {
    transform: (value: boolean | string) =>
      typeof value === 'string' ? value !== 'false' : value
  });

  // Używanie w computed
  isAdult = computed(() => this.age() >= 18);

  // Używanie w effect
  constructor() {
    effect(() => {
      console.log('User changed:', this.name());
    });
  }
}

Porównanie z @Input():

// Stary sposób
@Component({...})
export class OldComponent {
  @Input() name!: string;
  @Input() age = 0;

  // Reagowanie na zmiany wymaga ngOnChanges
  ngOnChanges(changes: SimpleChanges) {
    if (changes['name']) {
      console.log('Name changed');
    }
  }
}

// Nowy sposób
@Component({...})
export class NewComponent {
  name = input.required<string>();
  age = input(0);

  // Reagowanie na zmiany przez effect
  constructor() {
    effect(() => {
      console.log('Name changed:', this.name());
    });
  }

  // Lub computed dla derived values
  greeting = computed(() => `Hello, ${this.name()}!`);
}

Jak działają signal outputs (output())?

Od Angular 17.3 masz signal-based outputs:

import { Component, output, OutputEmitterRef } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <button (click)="increment()">+</button>
    <button (click)="reset()">Reset</button>
  `
})
export class CounterComponent {
  // Prosty output
  countChange = output<number>();

  // Output z aliasem
  resetEvent = output<void>({ alias: 'onReset' });

  private count = 0;

  increment() {
    this.count++;
    this.countChange.emit(this.count);
  }

  reset() {
    this.count = 0;
    this.resetEvent.emit();
  }
}

// Użycie w parent
@Component({
  template: `
    <app-counter
      (countChange)="onCountChange($event)"
      (onReset)="onReset()"
    />
  `
})
export class ParentComponent {
  onCountChange(count: number) {
    console.log('Count:', count);
  }

  onReset() {
    console.log('Reset!');
  }
}

outputFromObservable - konwersja Observable → Output:

import { outputFromObservable } from '@angular/core/rxjs-interop';

@Component({...})
export class SearchComponent {
  private searchSubject = new Subject<string>();

  // Observable jako output
  searchChange = outputFromObservable(
    this.searchSubject.pipe(
      debounceTime(300),
      distinctUntilChanged()
    )
  );

  onInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchSubject.next(value);
  }
}

Jak działają signal queries (viewChild, contentChild)?

import {
  Component,
  viewChild,
  viewChildren,
  contentChild,
  contentChildren,
  ElementRef
} from '@angular/core';

@Component({
  selector: 'app-parent',
  template: `
    <input #searchInput />
    <app-child #child />
    <app-item *ngFor="let item of items" />
  `
})
export class ParentComponent {
  // ViewChild jako Signal
  searchInput = viewChild<ElementRef>('searchInput');
  child = viewChild(ChildComponent);

  // Required viewChild
  requiredChild = viewChild.required(ChildComponent);

  // ViewChildren jako Signal<QueryList>
  items = viewChildren(ItemComponent);

  // ContentChild/ContentChildren podobnie
  projectedContent = contentChild<ElementRef>('content');
  projectedItems = contentChildren(ItemComponent);

  ngAfterViewInit() {
    // Dostęp przez ()
    console.log(this.searchInput()?.nativeElement);
    console.log(this.items().length);
  }

  // Użycie w computed
  hasItems = computed(() => this.items().length > 0);

  // Reagowanie na zmiany
  constructor() {
    effect(() => {
      const items = this.items();
      console.log('Items count:', items.length);
    });
  }
}

Jak działa model() - two-way binding z Signals?

model() to signal-based alternative dla @Input() + @Output() pattern:

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

@Component({
  selector: 'app-toggle',
  template: `
    <button (click)="toggle()">
      {{ value() ? 'ON' : 'OFF' }}
    </button>
  `
})
export class ToggleComponent {
  // Two-way bindable signal
  value = model(false);

  // Required model
  requiredValue = model.required<boolean>();

  toggle() {
    this.value.update(v => !v);
    // Automatycznie emituje zmianę do parent
  }
}

// Parent component
@Component({
  template: `
    <!-- Two-way binding -->
    <app-toggle [(value)]="isEnabled" />

    <!-- One-way + event -->
    <app-toggle [value]="isEnabled" (valueChange)="onToggle($event)" />
  `
})
export class ParentComponent {
  isEnabled = signal(false);

  onToggle(value: boolean) {
    console.log('Toggled:', value);
  }
}

Zoneless Angular

Jak działa change detection z Signals vs zone.js?

Zone.js (tradycyjny):

User click
    ↓
zone.js patchuje addEventListener
    ↓
Handler wykonuje się
    ↓
zone.js wykrywa zakończenie async
    ↓
Angular uruchamia change detection od root
    ↓
Sprawdza WSZYSTKIE komponenty (dirty checking)
    ↓
Aktualizuje DOM gdzie potrzeba

Signals (zoneless):

User click
    ↓
Handler wywołuje signal.set()
    ↓
Signal powiadamia Angular
    ↓
Angular wie DOKŁADNIE które bindings zależą od tego signal
    ↓
Aktualizuje TYLKO te elementy DOM
// Zone.js - Angular nie wie co się zmieniło
@Component({
  template: `
    <p>{{ user.name }}</p>
    <p>{{ user.age }}</p>
    <p>{{ unrelatedData }}</p>
  `
})
export class ZoneComponent {
  user = { name: 'John', age: 30 };
  unrelatedData = 'static';

  updateName() {
    this.user.name = 'Jane';
    // Angular sprawdzi WSZYSTKIE bindings
  }
}

// Signals - Angular wie dokładnie co się zmieniło
@Component({
  template: `
    <p>{{ user().name }}</p>
    <p>{{ user().age }}</p>
    <p>{{ unrelatedData }}</p>
  `
})
export class SignalComponent {
  user = signal({ name: 'John', age: 30 });
  unrelatedData = 'static';

  updateName() {
    this.user.update(u => ({ ...u, name: 'Jane' }));
    // Angular aktualizuje TYLKO {{ user().name }}
  }
}

Jak włączyć zoneless mode?

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import {
  provideExperimentalZonelessChangeDetection
} from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
});

// Usuń zone.js z angular.json
{
  "build": {
    "options": {
      "polyfills": [
        // "zone.js"  <- usuń lub zakomentuj
      ]
    }
  }
}

Co musi być zmienione dla zoneless:

// ❌ Nie zadziała w zoneless
@Component({...})
export class BrokenComponent {
  count = 0;

  increment() {
    this.count++; // Angular nie wie o tej zmianie!
  }
}

// ✅ Działa w zoneless
@Component({...})
export class WorkingComponent {
  count = signal(0);

  increment() {
    this.count.update(c => c + 1); // Signal powiadamia Angular
  }
}

// ✅ Lub explicit markForCheck
@Component({...})
export class ManualComponent {
  private cdr = inject(ChangeDetectorRef);
  count = 0;

  increment() {
    this.count++;
    this.cdr.markForCheck(); // Manual trigger
  }
}

Jakie są korzyści z zoneless Angular?

  1. Mniejszy bundle - zone.js to ~35KB (minified)
  2. Lepsza performance - brak overhead zone.js patching
  3. Precyzyjne updates - tylko zmienione elementy
  4. Prostsze debugowanie - brak "magii" zone.js
  5. Lepsze dla mikrofrontendów - brak konfliktów zone.js
// Benchmarks (przykładowe)
// Zone.js mode:
// - Bundle: +35KB
// - Change detection: sprawdza 1000 bindings
// - Time: 16ms

// Zoneless mode:
// - Bundle: -35KB
// - Change detection: sprawdza 3 zmienione bindings
// - Time: 0.5ms

Kiedy NIE używać zoneless?

Pozostań z zone.js jeśli:

  1. Legacy codebase - dużo komponentów bez Signals
  2. Third-party libraries - mogą polegać na zone.js
  3. setTimeout/setInterval - wymagają manual trigger
  4. Async/await - wymaga manual trigger lub RxJS
// Problem z async w zoneless
@Component({...})
export class AsyncComponent {
  data = signal<Data | null>(null);

  async loadData() {
    const response = await fetch('/api/data');
    const data = await response.json();

    // ✅ Signal automatycznie triggeruje update
    this.data.set(data);
  }

  // Ale setTimeout wymaga manual handling
  delayedAction() {
    setTimeout(() => {
      // ❌ W zoneless to nie triggeruje change detection
      this.someProperty = 'new value';

      // ✅ Użyj signal
      this.someSignal.set('new value');

      // ✅ Lub manual trigger
      this.cdr.markForCheck();
    }, 1000);
  }
}

Best Practices i Patterns

Jakie są best practices dla Signals?

1. Preferuj computed nad effect dla derived state:

// ❌ Anti-pattern
export class BadComponent {
  firstName = signal('');
  lastName = signal('');
  fullName = signal('');

  constructor() {
    effect(() => {
      // Nie używaj effect do derived state!
      this.fullName.set(`${this.firstName()} ${this.lastName()}`);
    }, { allowSignalWrites: true });
  }
}

// ✅ Correct
export class GoodComponent {
  firstName = signal('');
  lastName = signal('');
  fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
}

2. Nie mutuj obiektów w signals:

// ❌ Anti-pattern
const user = signal({ name: 'John', age: 30 });
user().name = 'Jane'; // Mutacja nie triggeruje update!

// ✅ Correct
user.update(u => ({ ...u, name: 'Jane' }));

// ✅ Dla tablic
const items = signal(['a', 'b']);
items.update(arr => [...arr, 'c']);

3. Expose readonly signals z serwisów:

@Injectable({ providedIn: 'root' })
export class CartService {
  private _items = signal<CartItem[]>([]);
  private _total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price, 0)
  );

  // Public API - readonly
  readonly items = this._items.asReadonly();
  readonly total = this._total;

  addItem(item: CartItem) {
    this._items.update(items => [...items, item]);
  }
}

4. Unikaj effect gdzie możesz użyć computed:

// ❌ Overuse of effect
effect(() => {
  const result = heavyComputation(this.input());
  this.output.set(result);
}, { allowSignalWrites: true });

// ✅ Use computed
output = computed(() => heavyComputation(this.input()));

5. Grupuj powiązane signals:

// ❌ Rozproszone signals
firstName = signal('');
lastName = signal('');
email = signal('');
age = signal(0);

// ✅ Zgrupowane w obiekt
user = signal<User>({
  firstName: '',
  lastName: '',
  email: '',
  age: 0
});

// Lub osobny serwis/store

Jak strukturyzować state z Signals?

Pattern 1: Component-level signals

@Component({...})
export class FormComponent {
  // UI state
  isSubmitting = signal(false);
  errors = signal<string[]>([]);

  // Form data
  formData = signal<FormData>({ name: '', email: '' });

  // Derived
  isValid = computed(() =>
    this.formData().name.length > 0 &&
    this.formData().email.includes('@')
  );
  canSubmit = computed(() => this.isValid() && !this.isSubmitting());
}

Pattern 2: Service-based store

@Injectable({ providedIn: 'root' })
export class TodoStore {
  // Private state
  private _todos = signal<Todo[]>([]);
  private _filter = signal<'all' | 'active' | 'completed'>('all');

  // Public selectors
  readonly todos = this._todos.asReadonly();
  readonly filter = this._filter.asReadonly();

  readonly filteredTodos = computed(() => {
    const todos = this._todos();
    const filter = this._filter();

    switch (filter) {
      case 'active': return todos.filter(t => !t.completed);
      case 'completed': return todos.filter(t => t.completed);
      default: return todos;
    }
  });

  readonly stats = computed(() => ({
    total: this._todos().length,
    active: this._todos().filter(t => !t.completed).length,
    completed: this._todos().filter(t => t.completed).length
  }));

  // Actions
  addTodo(title: string) {
    this._todos.update(todos => [...todos, {
      id: crypto.randomUUID(),
      title,
      completed: false
    }]);
  }

  toggleTodo(id: string) {
    this._todos.update(todos =>
      todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
    );
  }

  setFilter(filter: 'all' | 'active' | 'completed') {
    this._filter.set(filter);
  }
}

Pattern 3: Feature store z NgRx SignalStore

import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';

export const TodoStore = signalStore(
  withState<TodoState>({
    todos: [],
    filter: 'all',
    loading: false
  }),

  withComputed(({ todos, filter }) => ({
    filteredTodos: computed(() => {
      const f = filter();
      return todos().filter(t =>
        f === 'all' ? true :
        f === 'active' ? !t.completed :
        t.completed
      );
    })
  })),

  withMethods((store) => ({
    addTodo(title: string) {
      patchState(store, state => ({
        todos: [...state.todos, { id: Date.now(), title, completed: false }]
      }));
    }
  }))
);

Jak migrować z RxJS/BehaviorSubject do Signals?

Migracja serwisu:

// PRZED - RxJS
@Injectable({ providedIn: 'root' })
export class UserServiceOld {
  private userSubject = new BehaviorSubject<User | null>(null);
  user$ = this.userSubject.asObservable();

  isLoggedIn$ = this.user$.pipe(map(user => !!user));

  login(user: User) {
    this.userSubject.next(user);
  }
}

// PO - Signals
@Injectable({ providedIn: 'root' })
export class UserServiceNew {
  private _user = signal<User | null>(null);

  readonly user = this._user.asReadonly();
  readonly isLoggedIn = computed(() => !!this._user());

  login(user: User) {
    this._user.set(user);
  }
}

// Dla kompatybilności wstecznej podczas migracji
@Injectable({ providedIn: 'root' })
export class UserServiceHybrid {
  private _user = signal<User | null>(null);

  // Signal API
  readonly user = this._user.asReadonly();
  readonly isLoggedIn = computed(() => !!this._user());

  // Observable API (dla legacy komponentów)
  readonly user$ = toObservable(this._user);
  readonly isLoggedIn$ = toObservable(this.isLoggedIn);

  login(user: User) {
    this._user.set(user);
  }
}

Migracja komponentu:

// PRZED
@Component({...})
export class OldComponent implements OnInit, OnDestroy {
  user: User | null = null;
  private destroy$ = new Subject<void>();

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.user$
      .pipe(takeUntil(this.destroy$))
      .subscribe(user => this.user = user);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// PO
@Component({...})
export class NewComponent {
  private userService = inject(UserService);

  // Bezpośredni dostęp do signal
  user = this.userService.user;

  // Derived
  greeting = computed(() => {
    const user = this.user();
    return user ? `Hello, ${user.name}!` : 'Please login';
  });

  // Nie potrzeba ngOnInit, ngOnDestroy, takeUntil!
}

Zaawansowane Patterns

Jak obsługiwać async data z Signals?

Pattern: Resource signal (Angular 19+)

import { resource } from '@angular/core';

@Component({...})
export class UserComponent {
  userId = input.required<number>();

  // Resource automatycznie fetch'uje gdy userId się zmieni
  userResource = resource({
    request: () => this.userId(),
    loader: async ({ request: id }) => {
      const response = await fetch(`/api/users/${id}`);
      return response.json() as User;
    }
  });

  // W template
  // @if (userResource.isLoading()) {
  //   <p>Loading...</p>
  // } @else if (userResource.error()) {
  //   <p>Error: {{ userResource.error() }}</p>
  // } @else {
  //   <p>{{ userResource.value()?.name }}</p>
  // }
}

Pattern: Manual loading state

@Component({...})
export class DataComponent {
  private http = inject(HttpClient);

  // State
  data = signal<Data | null>(null);
  loading = signal(false);
  error = signal<string | null>(null);

  // Derived
  state = computed(() => ({
    data: this.data(),
    loading: this.loading(),
    error: this.error()
  }));

  async loadData() {
    this.loading.set(true);
    this.error.set(null);

    try {
      const data = await firstValueFrom(
        this.http.get<Data>('/api/data')
      );
      this.data.set(data);
    } catch (err) {
      this.error.set(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      this.loading.set(false);
    }
  }
}

Jak testować komponenty z Signals?

import { ComponentFixture, TestBed } from '@angular/core/testing';

describe('CounterComponent', () => {
  let component: CounterComponent;
  let fixture: ComponentFixture<CounterComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(CounterComponent);
    component = fixture.componentInstance;
  });

  it('should increment count', () => {
    expect(component.count()).toBe(0);

    component.increment();

    expect(component.count()).toBe(1);
  });

  it('should compute double correctly', () => {
    component.count.set(5);

    expect(component.double()).toBe(10);
  });

  it('should update template', () => {
    component.count.set(42);
    fixture.detectChanges();

    const element = fixture.nativeElement.querySelector('.count');
    expect(element.textContent).toContain('42');
  });

  // Testing inputs (signal-based)
  it('should accept input', () => {
    // Dla signal inputs używamy fixture.componentRef.setInput
    fixture.componentRef.setInput('name', 'John');
    fixture.detectChanges();

    expect(component.name()).toBe('John');
  });
});

// Testing serwisu z Signals
describe('TodoStore', () => {
  let store: TodoStore;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    store = TestBed.inject(TodoStore);
  });

  it('should add todo', () => {
    store.addTodo('Test todo');

    expect(store.todos().length).toBe(1);
    expect(store.todos()[0].title).toBe('Test todo');
  });

  it('should filter todos', () => {
    store.addTodo('Todo 1');
    store.addTodo('Todo 2');
    store.toggleTodo(store.todos()[0].id);

    store.setFilter('active');
    expect(store.filteredTodos().length).toBe(1);

    store.setFilter('completed');
    expect(store.filteredTodos().length).toBe(1);
  });
});

linkedSignal - co to i kiedy używać?

linkedSignal (Angular 19+) to signal który automatycznie resetuje się gdy źródło się zmieni:

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

@Component({...})
export class PaginationComponent {
  // Źródło
  pageSize = signal(10);

  // Linked signal - resetuje się gdy pageSize się zmieni
  currentPage = linkedSignal(() => 1);
  // Kiedy pageSize się zmieni, currentPage wraca do 1

  // Bardziej złożony przykład
  searchQuery = signal('');
  selectedCategory = signal<string | null>(null);

  // Reset filters gdy searchQuery się zmieni
  filters = linkedSignal({
    source: this.searchQuery,
    computation: () => ({
      category: null,
      priceRange: null,
      sortBy: 'relevance'
    })
  });

  // Z custom logiką
  page = linkedSignal({
    source: this.searchQuery,
    computation: (source, previous) => {
      // Reset do 1 tylko jeśli query się zmieniło
      if (previous && source !== previous.source) {
        return 1;
      }
      return previous?.value ?? 1;
    }
  });
}

Zadania Praktyczne

1. Zbuduj Todo App z Signals

Wymagania:

  • Lista todo z add/toggle/delete
  • Filtrowanie (all/active/completed)
  • Computed stats (total, active, completed)
  • Persist do localStorage przez effect

2. Zaimplementuj Form z Signals

Wymagania:

  • Signal-based form state
  • Real-time validation z computed
  • Debounced async validation (username availability)
  • Submit handling z loading state

3. Migruj istniejący komponent

Weź komponent z BehaviorSubject i ngOnChanges, przepisz na:

  • Signal inputs
  • Computed zamiast pipe transformations
  • Effect zamiast subscription

4. Zbuduj data fetching hook

Wymagania:

  • Signal-based loading/error/data state
  • Automatic refetch gdy parametry się zmienią
  • Caching
  • Retry logic

Podsumowanie

Angular Signals to fundamentalna zmiana w reaktywności Angular. Na rozmowach rekrutacyjnych skup się na:

  1. Podstawy - signal(), computed(), effect() i różnice między nimi
  2. Signals vs RxJS - kiedy który, jak konwertować
  3. Signal-based APIs - input(), output(), viewChild(), model()
  4. Zoneless mode - jak działa, korzyści, ograniczenia
  5. Patterns - service stores, derived state, async handling
  6. Migration - jak przepisywać z BehaviorSubject

Signals nie zastępują RxJS - są komplementarne. Używaj Signals dla synchronicznego state, RxJS dla async operations i complex streams.

Powrót do blogu

Zostaw komentarz

Pamiętaj, że komentarze muszą zostać zatwierdzone przed ich opublikowaniem.