🚀 Angular: Observables vs Signals

Tutorial complet pentru dezvoltatori și pregătire interviuri

📚 Introducere

Angular oferă două paradigme puternice pentru gestionarea stării și a datelor reactive: Observables (RxJS) și Signals (Angular 16+). Înțelegerea diferențelor și a cazurilor de utilizare este crucială pentru orice dezvoltator Angular modern.

🎯 Obiectiv: După parcurgerea acestui tutorial, vei înțelege când și cum să folosești Observables vs Signals, și vei fi pregătit pentru întrebările comune de interviu pe această temă.

🔄 Observables (RxJS)

Ce sunt Observables?

Observables sunt stream-uri de date asincrone care pot emite valori multiple în timp. Sunt lazy (se execută doar când cineva se abonează) și oferă operatori puternici pentru transformarea datelor.

// Exemplu de bază Observable
import { Observable, of, interval } from 'rxjs';
import { map, filter, debounceTime, take } from 'rxjs/operators';

// Observable simplu
const myObservable = new Observable<string>(observer => {
  observer.next('Prima valoare');
  observer.next('A doua valoare');
  
  setTimeout(() => {
    observer.next('Valoare întârziată');
    observer.complete();
  }, 1000);
});

// Subscribing
myObservable.subscribe({
  next: (value: string) => console.log(value),
  complete: () => console.log('Completat!')
});

// Observable cu operatori
const numbers$ = interval(1000).pipe(
  filter(n => n % 2 === 0),
  map(n => n * 2),
  take(5)
);

// Emite: 0, 4, 8, 12, 16

Caracteristici cheie

🚀 Lazy Evaluation

Se execută doar când există un subscriber

♻️ Multicast

Pot avea multiple subscripții

🎯 Operatori

100+ operatori pentru transformări complexe

⏱️ Asincron

Perfect pentru operații asincrone

Avantaje

  • Operatori puternici (map, filter, switchMap)
  • Anulare ușoară (unsubscribe)
  • Compoziție complexă de stream-uri
  • Gestionare erori robustă
  • Backpressure handling

Dezavantaje

  • Curbă de învățare abruptă
  • Necesită unsubscribe manual
  • Verbose pentru cazuri simple
  • Memory leaks dacă nu se gestionează corect
  • Complexitate pentru începători

🛠️ Operatori RxJS

Operatorii RxJS sunt funcții pure care permit transformarea și manipularea stream-urilor de date. Sunt cheia puterii Observables și se împart în mai multe categorii.

📥 Creation Operators

Creează observables din diverse surse de date.

import { 
  of, from, interval, timer, fromEvent, 
  range, empty, NEVER, throwError 
} from 'rxjs';

// of - creează din valori statice
const values$ = of(1, 2, 3, 4, 5);

// from - creează din array, Promise, iterable
const fromArray$ = from([1, 2, 3, 4, 5]);
const fromPromise$ = from(fetch('/api/data'));

// interval - emite valori la interval regulat
const interval$ = interval(1000); // 0, 1, 2, 3...

// timer - întârzie prima emisie, apoi interval
const timer$ = timer(2000, 1000); // după 2s: 0, 1, 2...

// fromEvent - din DOM events
const clicks$ = fromEvent(button, 'click');

// range - secvență numerică
const range$ = range(1, 10); // 1, 2, 3... 10

map

Transformă valorile 1:1

users$.pipe(map(u => u.name))

filter

Filtrează pe condiții

nums$.pipe(filter(n => n > 0))

switchMap

Switch la nou observable

search$.pipe(switchMap(callAPI))

debounceTime

Întârzie până se liniștește

input$.pipe(debounceTime(300))

combineLatest

Combină ultimele valori

combineLatest([a$, b$])

catchError

Gestionează erorile

http$.pipe(catchError(handleErr))

⚡ Signals (Angular 16+)

Ce sunt Signals?

Signals sunt o nouă primitivă reactivă în Angular care oferă o modalitate simplă și performantă de a gestiona starea. Sunt sincrone, au tracking automat al dependențelor și nu necesită unsubscribe.

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

// Signal simplu
const count = signal(0);

// Citire valoare
console.log(count()); // 0

// Actualizare valoare
count.set(5);
count.update(value => value + 1);

// Computed signal
const doubleCount = computed(() => count() * 2);

// Effect - se execută automat când se schimbă dependențele
effect(() => {
  console.log(`Count este: ${count()}`);
});

// În component
@Component({
  template: `
    <div>Count: {{ count() }}</div>
    <div>Double: {{ doubleCount() }}</div>
    <button (click)="increment()">+</button>
  `
})
export class CounterComponent {
  count = signal(0);
  doubleCount = computed(() => this.count() * 2);
  
  increment(): void {
    this.count.update(v => v + 1);
  }
}

Caracteristici cheie

🎯 Simplicitate

API simplu și intuitiv

🔄 Reactiv

Tracking automat al dependențelor

⚡ Performanță

Fine-grained reactivity

🧹 No Cleanup

Nu necesită unsubscribe

Best Practice: Folosește Signals pentru starea locală a componentelor și Observables pentru operații asincrone complexe și stream-uri de date.

⚖️ Comparație Detaliată

Aspect Observables Signals
Natura Asincron Sincron
Valori Stream de valori multiple O singură valoare curentă
Lazy/Eager Lazy (necesită subscribe) Eager (valoare imediată)
Cleanup Necesită unsubscribe Automat
Operatori 100+ operatori RxJS computed(), effect()
Use Case HTTP, WebSocket, Events State management, UI updates
Performanță Bună pentru stream-uri Excelentă pentru UI updates
Learning Curve Abruptă Ușoară

Când să folosești ce?

Folosește Observables pentru:

  • 🌐 HTTP requests și comunicare API
  • 🔌 WebSocket connections
  • ⏱️ Timers și intervale
  • 🎮 Event streams complexe
  • 🔄 Operații care necesită retry/cancel
  • 🎯 Transformări complexe de date

Folosește Signals pentru:

  • 📦 State management local
  • 🎨 UI state (loading, errors)
  • 📊 Computed values simple
  • ⚡ Performanță optimă în templates
  • 🔢 Contoare și toggle-uri
  • 📝 Form state simplu

💻 Exemple Practice

Exemplu 1: Shopping Cart cu Signals

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  // State cu Signals
  private itemsSignal = signal<CartItem[]>([]);
  
  // Public API
  items = this.itemsSignal.asReadonly();
  
  // Computed values
  totalItems = computed(() => 
    this.itemsSignal().reduce((sum, item) => sum + item.quantity, 0)
  );
  
  totalPrice = computed(() => 
    this.itemsSignal().reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    )
  );
  
  isEmpty = computed(() => this.itemsSignal().length === 0);
  
  // Methods
  addItem(product: Product): void {
    this.itemsSignal.update(items => {
      const existing = items.find(i => i.id === product.id);
      if (existing) {
        return items.map(i => 
          i.id === product.id 
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
      }
      return [...items, { ...product, quantity: 1 }];
    });
  }
}

Exemplu 2: Search cu Observables

@Component({
  template: `
    <input [formControl]="searchControl" placeholder="Search...">
    <div *ngFor="let user of users$ | async">{{ user.name }}</div>
  `
})
export class SearchComponent implements OnInit {
  searchControl = new FormControl('');
  users$ = new BehaviorSubject<User[]>([]);
  
  ngOnInit(): void {
    this.searchControl.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      filter(term => term !== null && term.length > 2),
      switchMap(term => this.searchUsers(term!)),
      takeUntilDestroyed()
    ).subscribe(users => this.users$.next(users));
  }
  
  private searchUsers(term: string): Observable<User[]> {
    return this.http.get<User[]>(`/api/users/search?q=${term}`);
  }
}

🏋️ Exerciții Practice

Exercițiu 1: Todo List cu Signals

Creează un serviciu TodoService folosind Signals care să implementeze:

  • Adăugare/ștergere todo-uri
  • Marcare ca completat
  • Filtrare (all, active, completed)
  • Counter pentru todo-uri active
@Injectable({ providedIn: 'root' })
export class TodoService {
  private todosSignal = signal<Todo[]>([]);
  private filterSignal = signal<'all' | 'active' | 'completed'>('all');
  
  todos = this.todosSignal.asReadonly();
  filter = this.filterSignal.asReadonly();
  
  filteredTodos = computed(() => {
    const todos = this.todosSignal();
    const filter = this.filterSignal();
    
    switch (filter) {
      case 'active': return todos.filter(t => !t.completed);
      case 'completed': return todos.filter(t => t.completed);
      default: return todos;
    }
  });
  
  activeTodosCount = computed(() => 
    this.todosSignal().filter(t => !t.completed).length
  );
  
  addTodo(title: string): void {
    if (!title.trim()) return;
    
    const newTodo: Todo = {
      id: crypto.randomUUID(),
      title: title.trim(),
      completed: false,
      createdAt: new Date()
    };
    
    this.todosSignal.update(todos => [newTodo, ...todos]);
  }
  
  toggleTodo(id: string): void {
    this.todosSignal.update(todos =>
      todos.map(todo =>
        todo.id === id 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  }
}

🎯 Întrebări Frecvente la Interviu

1. Care este diferența principală între Observables și Signals?

Răspuns:

  • Observables sunt stream-uri asincrone care emit valori multiple în timp, sunt lazy (necesită subscribe), și oferă operatori complecși pentru transformări.
  • Signals sunt valori reactive sincrone care stochează o singură valoare curentă, sunt eager (citire imediată), și oferă tracking automat al dependențelor.
  • Observables sunt ideale pentru operații asincrone (HTTP, WebSocket), iar Signals pentru state management local și UI updates.

2. Cum previi memory leaks cu Observables?

Răspuns - 5 metode principale:

// 1. takeUntilDestroyed() - Angular 16+
data$ = this.service.getData().pipe(
  takeUntilDestroyed()
);

// 2. DestroyRef inject
constructor(private destroyRef: DestroyRef) {
  this.service.getData().pipe(
    takeUntilDestroyed(this.destroyRef)
  ).subscribe();
}

// 3. Async pipe în template
// Template: {{ data$ | async }}

// 4. Manual unsubscribe
private subscription: Subscription;
ngOnInit() {
  this.subscription = this.service.getData().subscribe();
}
ngOnDestroy() {
  this.subscription?.unsubscribe();
}

3. Cum convertești între Observables și Signals?

// Observable to Signal
import { toSignal } from '@angular/core/rxjs-interop';

const data$ = this.http.get('/api/data');
const dataSignal = toSignal(data$, { initialValue: [] });

// Signal to Observable  
import { toObservable } from '@angular/core/rxjs-interop';

const countSignal = signal(0);
const count$ = toObservable(countSignal);

4. Explică computed() și effect() în Signals

Răspuns:

  • computed() - Creează un signal derivat care se recalculează automat când dependențele se schimbă. Este lazy, memorized și read-only.
  • effect() - Execută side-effects când dependențele se schimbă. Rulează în contexte de injecție și se cleanup automat.
const price = signal(100);
const quantity = signal(2);

// Computed - derivă valoare nouă
const total = computed(() => price() * quantity());

// Effect - side effects
effect(() => {
  console.log(`Total changed to: ${total()}`);
  localStorage.setItem('total', total().toString());
});

5. Care sunt best practices pentru Signals în Angular?

Best Practices:

  1. Folosește asReadonly() pentru a expune signals publice
  2. Preferă update() în loc de set() pentru transformări
  3. Folosește computed() pentru valori derivate în loc de effect()
  4. Evită mutații directe ale obiectelor în signals
  5. Grupează signals related într-un serviciu

6. Explică diferențele între switchMap, mergeMap, concatMap și exhaustMap

// switchMap - anulează observable-ul anterior
searchTerm$.pipe(
  switchMap(term => searchService.search(term))
); // Perfect pentru search - anulează request-ul anterior

// mergeMap - execută toate în paralel
ids$.pipe(
  mergeMap(id => fetchUser(id))
); // Pentru operații independente, fără ordine

// concatMap - execută în ordine, unul după altul
requests$.pipe(
  concatMap(req => processRequest(req))
); // Pentru operații care trebuie să respecte ordinea

// exhaustMap - ignoră până se completează cel curent
saveButton$.pipe(
  exhaustMap(() => saveData())
); // Previne multiple submit-uri

7. Cum gestionezi error handling în RxJS?

// catchError - prinde și gestionează erorile
this.http.get('/api/data').pipe(
  catchError((error: HttpErrorResponse) => {
    console.error('Error:', error);
    // Returnează valoare default
    return of({ data: [], error: true });
    // SAU re-aruncă eroarea
    // return throwError(() => new Error('Failed'));
  })
);

// retry - încearcă din nou de n ori
this.http.get('/api/data').pipe(
  retry(3),
  catchError(error => of({ error: true }))
);

// retryWhen - retry cu logică customizată
this.http.get('/api/data').pipe(
  retryWhen(errors => errors.pipe(
    tap(error => console.log('Retrying:', error)),
    delay(1000),
    take(3)
  ))
);

// finalize - execută cod la sfârșit (success/error)
this.http.get('/api/data').pipe(
  finalize(() => {
    this.loading = false;
  })
);

8. Ce este Cold vs Hot Observable?

Cold Observable:

  • Creează un producer nou pentru fiecare subscriber
  • Fiecare subscription primește toate valorile de la început
  • Exemple: HTTP requests, timer(), of()

Hot Observable:

  • Partajează același producer între toți subscribers
  • Subscribers primesc doar valorile emise după subscription
  • Exemple: DOM events, Subject
// Cold - fiecare subscriber primește propria execuție
const cold$ = new Observable(observer => {
  console.log('Producer started');
  observer.next(Math.random());
});

cold$.subscribe(v => console.log('Sub1:', v));
cold$.subscribe(v => console.log('Sub2:', v));
// Output: Două valori diferite

// Hot - cu share() sau Subject
const hot$ = cold$.pipe(share());
const subject$ = new Subject();

// Convert Cold to Hot
const shared$ = this.http.get('/api/data').pipe(
  shareReplay(1)
);

9. Cum implementezi caching cu Observables?

@Injectable()
export class DataService {
  private cache = new Map<string, Observable<any>>();
  
  // Method 1: shareReplay pentru cache simplu
  getData(): Observable<Data[]> {
    if (!this.cache.has('data')) {
      const data$ = this.http.get<Data[]>('/api/data').pipe(
        shareReplay(1)
      );
      this.cache.set('data', data$);
    }
    return this.cache.get('data')!;
  }
  
  // Method 2: Cache cu expirare
  getDataWithTTL(ttl: number = 5000): Observable<Data[]> {
    const cacheKey = 'data-ttl';
    const cached = this.cache.get(cacheKey);
    
    if (cached) {
      return cached;
    }
    
    const data$ = this.http.get<Data[]>('/api/data').pipe(
      shareReplay(1),
      finalize(() => {
        // Clear cache după TTL
        setTimeout(() => {
          this.cache.delete(cacheKey);
        }, ttl);
      })
    );
    
    this.cache.set(cacheKey, data$);
    return data$;
  }
  
  // Method 3: Cache cu invalidare manuală
  clearCache(key?: string): void {
    if (key) {
      this.cache.delete(key);
    } else {
      this.cache.clear();
    }
  }
}

10. Cum testezi Observables și Signals?

// Testing Observables cu marble testing
describe('Observable Tests', () => {
  let scheduler: TestScheduler;
  
  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });
  
  it('should debounce search', () => {
    scheduler.run(({ hot, cold, expectObservable }) => {
      const input$ = hot('a-b-c---|');
      const expected = '   --c---|';
      
      const result$ = input$.pipe(debounceTime(20));
      
      expectObservable(result$).toBe(expected);
    });
  });
});

// Testing Signals
describe('Signal Tests', () => {
  it('should update computed when signal changes', () => {
    const count = signal(0);
    const doubled = computed(() => count() * 2);
    
    expect(doubled()).toBe(0);
    
    count.set(5);
    expect(doubled()).toBe(10);
  });
  
  it('should track effect execution', () => {
    const count = signal(0);
    let effectRuns = 0;
    
    effect(() => {
      count(); // trigger dependency
      effectRuns++;
    });
    
    expect(effectRuns).toBe(1);
    
    count.set(1);
    expect(effectRuns).toBe(2);
  });
});

📚 Resurse Utile

📖 Documentație Oficială

Angular Signals Guide și RxJS Documentation pentru referințe complete

🎥 Video Tutorials

Angular University și Decoded Frontend pentru explicații detaliate

🛠️ Tools & Libraries

RxJS DevTools, Marble Testing, @ngrx/signals pentru state management avansat

📝 Practice Platforms

RxJS Marbles, LeetCode pentru algoritmi reactive, StackBlitz pentru experimente

🎯 Concluzie

Înțelegerea diferențelor între Observables și Signals este crucială pentru dezvoltarea eficientă în Angular modern. Observables rămân puternice pentru operații asincrone complexe, în timp ce Signals oferă o modalitate mai simplă și performantă pentru gestionarea stării locale.

🚀 Next Steps: Practică implementarea ambelor paradigme în proiectele tale și experimentează cu combinarea lor pentru soluții hibride optimale.