Some operators remember the past (scan, reduce). Others control time (debounceTime, throttleTime, delay). Together they cover running totals, typing delays and timing effects.
What you will learn
scanversusreducedebounceTime,debounceandthrottleTimedelayanddelayWhen
scan and reduce: accumulate values
Both take a function (accumulated, current) => newAccumulated and a starting value.
- `scan` emits the running total after every value.
- `reduce` emits once, the final total, when the stream completes.
// src/app/rxjs4/accumulate.ts
import { Observable, from, reduce, scan } from 'rxjs';
export const numbers$ = from([1, 2, 3, 4]);
export const runningTotal$ = numbers$.pipe(scan((total, n) => total + n, 0)); // 1, 3, 6, 10
export const finalTotal$ = numbers$.pipe(reduce((total, n) => total + n, 0)); // 10
// scan is the engine of "state over time": a tiny store
export interface CartEvent {
type: 'add' | 'remove';
item: string;
}
export function cart(events$: Observable<CartEvent>): Observable<string[]> {
return events$.pipe(
scan((items, event) => (event.type === 'add' ? [...items, event.item] : items.filter((i) => i !== event.item)), [] as string[]),
);
}
Use scan for counters, history and shopping carts built from events. Use reduce when you only care about the result of a finite stream. Never use reduce on an endless stream, because it never emits.
debounceTime, debounce and throttleTime
All three tame fast streams, such as typing or scrolling.
- `debounceTime(300)` waits until the stream has been quiet for 300 ms, then emits the last value. Perfect for search boxes.
- `debounce(() => timer(300))` is the same idea, but the quiet period is decided by an Observable that can depend on the value.
- `throttleTime(300)` emits the first value, then ignores everything for 300 ms. Perfect for scroll, resize and "do not spam the button".
// src/app/rxjs4/timing.ts
import { Observable, debounce, debounceTime, delay, delayWhen, throttleTime, timer } from 'rxjs';
export const settled = (source$: Observable<string>) => source$.pipe(debounceTime(300));
// Short words are sent quickly, long words wait longer
export const smart = (source$: Observable<string>) =>
source$.pipe(debounce((text) => timer(text.length > 3 ? 500 : 100)));
export const limited = (source$: Observable<string>) => source$.pipe(throttleTime(300));
// delay shifts every value later by the same time
export const later = (source$: Observable<string>) => source$.pipe(delay(1000));
// delayWhen decides the delay for each value with an Observable
export const staggered = (source$: Observable<number>) =>
source$.pipe(delayWhen((n) => timer(n * 100)));
- `delay(ms)` postpones every value by a fixed time. It does not slow down errors.
- `delayWhen(fn)` postpones each value until the Observable returned by
fnemits. It can create staggered effects such as one item per 100 ms.
A debounced search in Angular
// src/app/rxjs4/search.ts
import { Component, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';
@Component({
selector: 'app-search',
imports: [ReactiveFormsModule],
template: `
<label for="q">Search</label>
<input id="q" [formControl]="query" />
<p>Typed: {{ query.value }}</p>
<p>Searching for: {{ term() }}</p>
`,
})
export class Search {
protected readonly query = new FormControl('', { nonNullable: true });
protected readonly searches = signal(0);
protected readonly term = toSignal(
this.query.valueChanges.pipe(
debounceTime(300), // wait for a pause in typing
map((v) => v.trim()),
distinctUntilChanged(), // skip repeated values
),
{ initialValue: '' },
);
}
// src/app/app.ts
import { Component } from '@angular/core';
import { Search } from './rxjs4/search';
@Component({
imports: [Search],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<app-search />
</main>
Tests with fake time
Time operators are easy to test with fake timers, because you decide when time passes:
// src/app/rxjs4/rxjs4.spec.ts
import { Subject, lastValueFrom, of, toArray } from 'rxjs';
import { cart, finalTotal$, runningTotal$ } from './accumulate';
import { later, limited, settled, smart, staggered } from './timing';
describe('accumulate and timing operators', () => {
afterEach(() => vi.useRealTimers());
it('scan emits the running total, reduce only the final one', async () => {
expect(await lastValueFrom(runningTotal$.pipe(toArray()))).toEqual([1, 3, 6, 10]);
expect(await lastValueFrom(finalTotal$.pipe(toArray()))).toEqual([10]);
});
it('scan builds state from events', () => {
const events$ = new Subject<{ type: 'add' | 'remove'; item: string }>();
let latest: string[] = [];
cart(events$).subscribe((items) => (latest = items));
events$.next({ type: 'add', item: 'apple' });
events$.next({ type: 'add', item: 'pear' });
events$.next({ type: 'remove', item: 'apple' });
expect(latest).toEqual(['pear']);
});
it('debounceTime emits only the last value after a pause', () => {
vi.useFakeTimers();
const source$ = new Subject<string>();
const seen: string[] = [];
settled(source$).subscribe((v) => seen.push(v));
source$.next('a');
vi.advanceTimersByTime(100);
source$.next('ab');
vi.advanceTimersByTime(100);
source$.next('abc');
expect(seen).toEqual([]);
vi.advanceTimersByTime(300);
expect(seen).toEqual(['abc']);
});
it('debounce can depend on the value', () => {
vi.useFakeTimers();
const source$ = new Subject<string>();
const seen: string[] = [];
smart(source$).subscribe((v) => seen.push(v));
source$.next('hi');
vi.advanceTimersByTime(100);
expect(seen).toEqual(['hi']);
source$.next('hello');
vi.advanceTimersByTime(100);
expect(seen).toEqual(['hi']);
vi.advanceTimersByTime(400);
expect(seen).toEqual(['hi', 'hello']);
});
it('throttleTime emits the first value and ignores the rest for a while', () => {
vi.useFakeTimers();
const source$ = new Subject<string>();
const seen: string[] = [];
limited(source$).subscribe((v) => seen.push(v));
source$.next('a');
source$.next('b');
vi.advanceTimersByTime(200);
source$.next('c');
expect(seen).toEqual(['a']);
vi.advanceTimersByTime(200);
source$.next('d');
expect(seen).toEqual(['a', 'd']);
});
it('delay and delayWhen postpone values', () => {
vi.useFakeTimers();
const seen: string[] = [];
later(of('x')).subscribe((v) => seen.push(v));
vi.advanceTimersByTime(999);
expect(seen).toEqual([]);
vi.advanceTimersByTime(2);
expect(seen).toEqual(['x']);
const order: number[] = [];
staggered(of(3, 1, 2)).subscribe((n) => order.push(n));
vi.advanceTimersByTime(400);
expect(order).toEqual([1, 2, 3]);
});
});
Try it yourself
- Use
scanto keep the last five search terms. - Use
throttleTimeon ascrollevent to log the scroll position at most every 200 ms. - Change
debounceTimetothrottleTimein the search box. What changes when you type quickly?
Quick check
- What is the difference between
scanandreduce? - Which operator suits a search box, and which suits scroll events?
- What does
delayWhendo thatdelaycannot?
Next, handle failures in error handling operators: throwError, catchError and retry.