Angular course Β· Module 7: RxJS

RxJS in Angular - Ninja Scouts

2 min read Β·
In this lesson4

RxJS (Reactive Extensions for JavaScript) is a library for reactive programming - like a network of ninja scouts observing the terrain and instantly reporting events to headquarters.

What is Reactive Programming?

It is a paradigm based on data streams and change propagation:

  • Streams - sequences of events spread over time
  • Observers - react to every event in the stream
  • Operators - transform, filter, combine streams

Observable - Data Stream

An Observable is an "observable" sequence of values - like a ninja watching a castle and reporting every movement:

import { Observable, of, from, interval, fromEvent } from 'rxjs';

// Creating an Observable from values
const numbers$ = of(1, 2, 3, 4, 5);  // Emits values and completes

// From an array
const fromArray$ = from([1, 2, 3]);

// Timer - emits every second
const timer$ = interval(1000);  // 0, 1, 2, 3...

// From DOM events
const clicks$ = fromEvent(document, 'click');

// Custom Observable
const custom$ = new Observable<number>(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  setTimeout(() => {
    subscriber.next(3);
    subscriber.complete();
  }, 1000);
});

Subscription - Listening

// Full subscription form
const subscription = numbers$.subscribe({
  next: value => console.log('Value:', value),
  error: err => console.error('Error:', err),
  complete: () => console.log('Completed')
});

// Short form - only next
numbers$.subscribe(value => console.log(value));

// Cancelling the subscription
subscription.unsubscribe();

Naming Convention

In Angular we use the $ suffix for Observables:

samurai$: Observable<Samurai[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
Code for this lesson: src/app/app.component.ts
import { Component, signal, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, interval, fromEvent, merge, BehaviorSubject, Subscription } from 'rxjs';
import { map, filter, takeUntil, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="ninja-hq">
      <h1>Ninja Headquarters - RxJS</h1>

      <section class="scouts-section">
        <h2>Scouts (Observables)</h2>

        <div class="scout-report">
          <h3>Time Report</h3>
          <p>Seconds since start: {{ timeElapsed() }}</p>
          <p>Even time: {{ evenTime() ? 'Yes' : 'No' }}</p>
        </div>

        <div class="scout-report">
          <h3>Mouse Tracking</h3>
          <p>Position X: {{ mouseX() }}</p>
          <p>Position Y: {{ mouseY() }}</p>
          <p>Zone: {{ mouseZone() }}</p>
        </div>
      </section>

      <section class="messages-section">
        <h2>Messages (Subject)</h2>

        <div class="message-input">
          <input
            #messageInput
            type="text"
            placeholder="Type a message..."
            (input)="onMessageInput(messageInput.value)"
          >
          <button (click)="sendMessage(messageInput.value); messageInput.value = ''">
            Send
          </button>
        </div>

        <div class="messages-log">
          <h3>Message history:</h3>
          @for (msg of messages(); track $index) {
            <div class="message" [class.urgent]="msg.urgent">
              <span class="time">{{ msg.time }}</span>
              <span class="text">{{ msg.text }}</span>
              @if (msg.urgent) {
                <span class="badge">URGENT</span>
              }
            </div>
          }
        </div>
      </section>

      <section class="operators-section">
        <h2>RxJS Operators</h2>

        <div class="search-demo">
          <h3>Search (debounce + distinctUntilChanged)</h3>
          <input
            type="text"
            placeholder="Search samurai..."
            (input)="onSearchInput($event)"
          >
          <p>Search term: "{{ searchTerm() }}"</p>
          <p>Number of searches: {{ searchCount() }}</p>
        </div>
      </section>
    </div>
  `,
  styles: [`
    .ninja-hq {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #e94560;
      text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
    }

    h2 {
      color: #0f3460;
      background: #e94560;
      padding: 10px;
      border-radius: 5px;
    }

    section {
      background: rgba(255,255,255,0.1);
      border-radius: 10px;
      padding: 15px;
      margin: 15px 0;
    }

    .scout-report {
      background: rgba(0,0,0,0.3);
      padding: 10px;
      border-radius: 5px;
      margin: 10px 0;
      border-left: 3px solid #e94560;
    }

    .scout-report h3 {
      margin: 0 0 10px 0;
      color: #e94560;
    }

    .message-input {
      display: flex;
      gap: 10px;
      margin-bottom: 15px;
    }

    .message-input input {
      flex: 1;
      padding: 10px;
      border: none;
      border-radius: 5px;
      background: #0f3460;
      color: white;
    }

    .message-input button {
      padding: 10px 20px;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }

    .message-input button:hover {
      background: #ff6b6b;
    }

    .messages-log {
      max-height: 200px;
      overflow-y: auto;
    }

    .message {
      background: rgba(0,0,0,0.3);
      padding: 8px;
      margin: 5px 0;
      border-radius: 5px;
      display: flex;
      gap: 10px;
      align-items: center;
    }

    .message.urgent {
      border: 2px solid #ff6b6b;
      background: rgba(255,107,107,0.2);
    }

    .message .time {
      color: #888;
      font-size: 0.8em;
    }

    .message .badge {
      margin-left: auto;
    }

    .search-demo input {
      width: 100%;
      padding: 10px;
      border: none;
      border-radius: 5px;
      background: #0f3460;
      color: white;
      margin: 10px 0;
    }
  `]
})
export class AppComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
  private messageSubject = new BehaviorSubject<Message[]>([]);
  private searchSubject = new Subject<string>();

  // Signals from Observables
  timeElapsed = signal(0);
  evenTime = signal(false);
  mouseX = signal(0);
  mouseY = signal(0);
  mouseZone = signal('Center');
  messages = signal<Message[]>([]);
  searchTerm = signal('');
  searchCount = signal(0);

  ngOnInit(): void {
    // Timer Observable - every second
    interval(1000).pipe(
      takeUntil(this.destroy$),
      tap(value => {
        this.timeElapsed.set(value);
        this.evenTime.set(value % 2 === 0);
      })
    ).subscribe();

    // Mouse Observable
    fromEvent<MouseEvent>(document, 'mousemove').pipe(
      takeUntil(this.destroy$),
      map(event => ({ x: event.clientX, y: event.clientY })),
      tap(pos => {
        this.mouseX.set(pos.x);
        this.mouseY.set(pos.y);
        this.mouseZone.set(this.getZone(pos.x, pos.y));
      })
    ).subscribe();

    // Messages BehaviorSubject
    this.messageSubject.pipe(
      takeUntil(this.destroy$)
    ).subscribe(msgs => this.messages.set(msgs));

    // Search with debounce
    this.searchSubject.pipe(
      takeUntil(this.destroy$),
      debounceTime(300),
      distinctUntilChanged(),
      tap(term => {
        this.searchTerm.set(term);
        this.searchCount.update(c => c + 1);
      })
    ).subscribe();
  }

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

  private getZone(x: number, y: number): string {
    if (x < 200 && y < 200) return 'β†– North-West';
    if (x > 400 && y < 200) return 'β†— North-East';
    if (x < 200 && y > 400) return '↙ South-West';
    if (x > 400 && y > 400) return 'β†˜ South-East';
    return 'Center';
  }

  onMessageInput(value: string): void {
    // Could add real-time typing indicator here
  }

  sendMessage(text: string): void {
    if (!text.trim()) return;

    const newMessage: Message = {
      text,
      time: new Date().toLocaleTimeString(),
      urgent: text.includes('!')
    };

    const current = this.messageSubject.getValue();
    this.messageSubject.next([...current, newMessage]);
  }

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

interface Message {
  text: string;
  time: string;
  urgent: boolean;
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which RxJS function creates an Observable that emits values at a fixed time interval?

  2. 2. What does the of() function do in RxJS?

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Arrange the Observable variable declaration following the Angular naming convention: samurai$: Observable<Samurai[]>;

  • Code editor

    Complete the code that creates an Observable from values and subscribes to it.

  • Horizontal ordering

    Arrange the import of Observable creation functions from the rxjs library:

  • Vertical ordering

    Arrange the observer object callbacks in the order in which we declare them in subscribe():

  • Click in order

    Arrange the short subscription form of a stream: numbers$.subscribe(fn)

Useful articles