Skip to main content

Overview

Applies an accumulator (or “reducer”) function to each value from the source Observable. Like reduce, but emits the current accumulation state after processing each value, rather than waiting until the source completes.
Think of scan as reduce that shows you the work in progress. It’s perfect for maintaining running totals, building up state, or implementing accumulation patterns.

Type Signature

function scan<V, A = V>(
  accumulator: (acc: A | V, value: V, index: number) => A
): OperatorFunction<V, V | A>

function scan<V, A>(
  accumulator: (acc: A, value: V, index: number) => A,
  seed: A
): OperatorFunction<V, A>

function scan<V, A, S>(
  accumulator: (acc: A | S, value: V, index: number) => A,
  seed: S
): OperatorFunction<V, A>

Parameters

accumulator
(acc: A, value: V, index: number) => A
required
A “reducer function” called for each source value. Receives:
  • acc: The accumulated value (either the seed or the previous result)
  • value: The current value from the source
  • index: The zero-based index of the emission
Must return the next accumulated value.
seed
S
Optional initial accumulation value. If provided, it’s used as the initial state and the first source value goes through the accumulator. If omitted, the first source value is used as the initial state and emitted without going through the accumulator.

Returns

return
OperatorFunction<V, V | A>
A function that returns an Observable of accumulated values. Each emission represents the current state of accumulation.

Usage Examples

Basic Example: Running Total

import { of, scan } from 'rxjs';

const numbers = of(1, 2, 3, 4, 5);
const runningTotal = numbers.pipe(
  scan((acc, value) => acc + value)
);

runningTotal.subscribe(x => console.log(x));
// Output:
// 1    (first value, no accumulation)
// 3    (1 + 2)
// 6    (3 + 3)
// 10   (6 + 4)
// 15   (10 + 5)

Running Average

import { of, scan, map } from 'rxjs';

const numbers = of(1, 2, 3, 4, 5);

numbers.pipe(
  scan((acc, value) => acc + value, 0),
  map((sum, index) => sum / (index + 1))
).subscribe(avg => console.log('Average:', avg));

// Output:
// Average: 1     (1/1)
// Average: 1.5   (3/2)
// Average: 2     (6/3)
// Average: 2.5   (10/4)
// Average: 3     (15/5)

Building Arrays

import { interval, scan, take } from 'rxjs';

interval(1000).pipe(
  take(5),
  scan((acc, value) => [...acc, value], [] as number[])
).subscribe(array => console.log(array));

// Output:
// [0]
// [0, 1]
// [0, 1, 2]
// [0, 1, 2, 3]
// [0, 1, 2, 3, 4]

Fibonacci Sequence

import { interval, scan, map, startWith } from 'rxjs';

const firstTwoFibs = [0, 1];

const fibonacci$ = interval(1000).pipe(
  scan(([a, b]) => [b, a + b], firstTwoFibs),
  map(([, n]) => n),
  startWith(...firstTwoFibs)
);

fibonacci$.subscribe(n => console.log(n));
// Output: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...

Marble Diagram

Source:  --1--2--3--4--5--|
scan((acc, v) => acc + v, 0)
Result:  --1--3--6--10-15-|

Common Use Cases

  1. Running Totals: Sum, count, or aggregate values over time
  2. State Management: Build up application state from events
  3. Collecting Results: Accumulate items into collections
  4. Running Calculations: Moving averages, statistics
  5. Event History: Build a history of events
  6. Redux-Style Reducers: Implement state machines
The key difference between scan and reduce: scan emits intermediate results, reduce only emits the final result when the source completes.

Advanced Example: Shopping Cart State

import { Subject, scan, map } from 'rxjs';

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

interface CartState {
  items: CartItem[];
  total: number;
  itemCount: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'UPDATE_QUANTITY'; id: string; quantity: number }
  | { type: 'CLEAR' };

const actions$ = new Subject<CartAction>();

const initialState: CartState = {
  items: [],
  total: 0,
  itemCount: 0
};

const cart$ = actions$.pipe(
  scan((state: CartState, action: CartAction): CartState => {
    switch (action.type) {
      case 'ADD_ITEM': {
        const existingIndex = state.items.findIndex(
          item => item.id === action.item.id
        );
        
        let items: CartItem[];
        if (existingIndex >= 0) {
          items = [...state.items];
          items[existingIndex] = {
            ...items[existingIndex],
            quantity: items[existingIndex].quantity + action.item.quantity
          };
        } else {
          items = [...state.items, action.item];
        }
        
        return calculateTotals(items);
      }
      
      case 'REMOVE_ITEM': {
        const items = state.items.filter(item => item.id !== action.id);
        return calculateTotals(items);
      }
      
      case 'UPDATE_QUANTITY': {
        const items = state.items.map(item =>
          item.id === action.id
            ? { ...item, quantity: action.quantity }
            : item
        );
        return calculateTotals(items);
      }
      
      case 'CLEAR':
        return initialState;
      
      default:
        return state;
    }
  }, initialState)
);

function calculateTotals(items: CartItem[]): CartState {
  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  const itemCount = items.reduce(
    (count, item) => count + item.quantity,
    0
  );
  return { items, total, itemCount };
}

// Subscribe to cart updates
cart$.subscribe(state => {
  console.log('Cart updated:', state);
  updateUI(state);
});

// Dispatch actions
actions$.next({
  type: 'ADD_ITEM',
  item: { id: '1', name: 'Widget', price: 9.99, quantity: 2 }
});

actions$.next({
  type: 'ADD_ITEM',
  item: { id: '2', name: 'Gadget', price: 19.99, quantity: 1 }
});

actions$.next({
  type: 'UPDATE_QUANTITY',
  id: '1',
  quantity: 3
});

Event Counter by Type

import { fromEvent, scan, map, merge } from 'rxjs';

interface EventCounts {
  clicks: number;
  keypresses: number;
  scrolls: number;
}

const clicks$ = fromEvent(document, 'click').pipe(map(() => 'click'));
const keys$ = fromEvent(document, 'keypress').pipe(map(() => 'keypress'));
const scrolls$ = fromEvent(document, 'scroll').pipe(map(() => 'scroll'));

const eventCounts$ = merge(clicks$, keys$, scrolls$).pipe(
  scan((counts: EventCounts, eventType: string) => {
    switch (eventType) {
      case 'click':
        return { ...counts, clicks: counts.clicks + 1 };
      case 'keypress':
        return { ...counts, keypresses: counts.keypresses + 1 };
      case 'scroll':
        return { ...counts, scrolls: counts.scrolls + 1 };
      default:
        return counts;
    }
  }, { clicks: 0, keypresses: 0, scrolls: 0 })
);

eventCounts$.subscribe(counts => {
  console.log('Event counts:', counts);
  updateDashboard(counts);
});

Building Objects from Stream

import { interval, scan, take, map } from 'rxjs';

interface User {
  id: number;
  name: string;
  score: number;
}

const updates = interval(1000).pipe(
  take(5),
  map(i => ({
    id: i,
    name: `User${i}`,
    score: Math.floor(Math.random() * 100)
  }))
);

const userMap$ = updates.pipe(
  scan((map, user) => {
    map.set(user.id, user);
    return map;
  }, new Map<number, User>()),
  map(map => Array.from(map.values()))
);

userMap$.subscribe(users => {
  console.log('Current users:', users);
});

Using Index Parameter

import { of, scan } from 'rxjs';

of('a', 'b', 'c', 'd').pipe(
  scan((acc, value, index) => {
    return `${acc} [${index}:${value}]`;
  }, 'Start:')
).subscribe(x => console.log(x));

// Output:
// Start: [0:a]
// Start: [0:a] [1:b]
// Start: [0:a] [1:b] [2:c]
// Start: [0:a] [1:b] [2:c] [3:d]

Error Handling

If the accumulator function throws an error, the error is propagated to the subscriber and the Observable terminates. The accumulated state is lost.
import { of, scan, catchError } from 'rxjs';

of(1, 2, 3, -1, 5).pipe(
  scan((acc, value) => {
    if (value < 0) {
      throw new Error('Negative value not allowed');
    }
    return acc + value;
  }, 0),
  catchError(err => {
    console.error('Error:', err.message);
    return of(-1);
  })
).subscribe(x => console.log(x));

// Output: 1, 3, 6, -1 (error caught)

Performance Considerations

When accumulating into objects or arrays, be mindful of memory usage. Consider limiting the size of accumulated collections or using immutable update patterns.
// Memory-efficient: limit array size
scan((acc, value) => {
  const newArr = [...acc, value];
  // Keep only last 100 items
  return newArr.slice(-100);
}, [] as number[])

// Efficient object updates
scan((state, update) => ({
  ...state,
  ...update
}), initialState)
  • reduce - Like scan, but only emits the final accumulated value
  • expand - Recursively projects values
  • mergeScan - Like scan but with Observable accumulator
  • switchScan - Like scan but switches to new inner Observable
  • bufferCount - Accumulate into arrays of fixed size