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:
- Fine-grained reactivity - zamiast sprawdzać cały komponent, Angular aktualizuje konkretne elementy DOM
- Zoneless mode - eliminacja zone.js (mniejszy bundle, lepsza performance)
- Prostszy mental model - łatwiejsze niż RxJS dla synchronicznego state
- Lepsze DevTools - łatwiejsze debugowanie (wartość jest zawsze dostępna)
- 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?
- Mniejszy bundle - zone.js to ~35KB (minified)
- Lepsza performance - brak overhead zone.js patching
- Precyzyjne updates - tylko zmienione elementy
- Prostsze debugowanie - brak "magii" zone.js
- 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:
- Legacy codebase - dużo komponentów bez Signals
- Third-party libraries - mogą polegać na zone.js
- setTimeout/setInterval - wymagają manual trigger
- 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:
- Podstawy - signal(), computed(), effect() i różnice między nimi
- Signals vs RxJS - kiedy który, jak konwertować
- Signal-based APIs - input(), output(), viewChild(), model()
- Zoneless mode - jak działa, korzyści, ograniczenia
- Patterns - service stores, derived state, async handling
- 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.