RXJS Reduce vs Scan: What's the Difference and Why They Behave Differently in Your Code
Reactive programming with RXJS has revolutionized how developers handle asynchronous data streams, from user interactions to API responses. Among the many operators in RXJS, reduce and scan are two of the most commonly used for accumulating values over time. At first glance, they seem similar—both take an accumulator function and a seed value to process a stream of data. However, their behavior differs drastically, and choosing the wrong one can lead to bugs, performance issues, or unexpected results.
In this blog, we’ll demystify reduce and scan, break down their core differences, explore practical use cases, and highlight common pitfalls. By the end, you’ll know exactly when to reach for reduce and when scan is the better choice.
Table of Contents#
- What Are RXJS
reduceandscan? - Key Differences Between
reduceandscan - How
reduceWorks: A Deep Dive - How
scanWorks: A Deep Dive - Practical Examples: When to Use Each
- Common Pitfalls and How to Avoid Them
- Conclusion
- References
What Are RXJS reduce and scan?#
Both reduce and scan are accumulation operators in RXJS. They process a stream of values by applying an accumulator function to each incoming value, updating a running "accumulator" (a variable that holds the intermediate result), and emitting a final or intermediate value based on the accumulator.
Core Purpose:#
- Accumulation: Transform a stream of individual values into a single aggregated result (for
reduce) or a stream of intermediate aggregated results (forscan). - State Tracking: Maintain state across multiple emissions (e.g., summing numbers, building an object, or tracking user interactions).
Key Differences Between reduce and scan#
The critical distinction lies in when and how often they emit values. Let’s break down their differences with a comparison table:
| Feature | reduce | scan |
|---|---|---|
| Emission Timing | Emits only once, when the source observable completes. | Emits after every source emission (including the first, depending on the seed). |
| Number of Emissions | 0 or 1 emission (0 if source never completes). | 1+ emissions (one per source value, after accumulation). |
| Seed Requirement | Optional, but behavior changes if omitted (uses first source value as initial accumulator). | Optional, but behavior changes if omitted (emits first source value as-is, then accumulates). |
| Behavior with Incomplete Source | Never emits (even if values were processed). | Emits intermediate results for each received value. |
| Primary Use Case | Calculating a final aggregated result (e.g., total, average, final state). | Tracking intermediate state changes (e.g., real-time counters, progress updates). |
How reduce Works: A Deep Dive#
The reduce operator aggregates values over the entire lifecycle of a stream and emits the final result only when the source observable completes. Think of it as the RXJS equivalent of JavaScript’s Array.reduce, but for asynchronous streams.
Signature#
reduce<T, R>(
accumulator: (acc: R, value: T, index: number) => R,
seed?: R
): OperatorFunction<T, R> accumulator: A function that takes the current accumulator (acc), the latest source value (value), and the emission index (index), then returns the updated accumulator.seed(optional): The initial value of the accumulator. If omitted,reduceuses the first source emission as the initial accumulator and starts processing from the second emission.
How It Works: Step-by-Step#
-
Initialize the accumulator:
- If a
seedis provided, start withseed. - If no
seedis provided, wait for the first source emission and use it as the initial accumulator.
- If a
-
Process source emissions:
- For each subsequent source value, apply the
accumulatorfunction to update the accumulator.
- For each subsequent source value, apply the
-
Emit the final result:
- Only when the source observable completes, emit the final accumulator value.
Example 1: Summing Numbers with reduce#
Let’s use reduce to sum a stream of numbers [1, 2, 3] with a seed of 0:
import { of } from 'rxjs';
import { reduce } from 'rxjs/operators';
const numberStream = of(1, 2, 3); // Emits 1, 2, 3, then completes.
numberStream.pipe(
reduce((acc, value) => acc + value, 0) // Seed = 0
).subscribe({
next: (total) => console.log('Total:', total), // Emits once: "Total: 6"
complete: () => console.log('Stream completed')
}); Output:
Total: 6
Stream completed
Why? The source emits 1 (acc becomes 0+1=1), then 2 (acc 3), then 3 (acc 6). Only when the source completes does reduce emit the final accumulator (6).
Example 2: reduce Without a Seed#
If no seed is provided, reduce uses the first source value as the initial accumulator and starts processing from the second value:
const numberStream = of(1, 2, 3);
numberStream.pipe(
reduce((acc, value) => acc + value) // No seed
).subscribe({ next: (total) => console.log('Total:', total) }); Output:
Total: 6
Breakdown:
- First emission (
1): Used as initialacc(no processing yet). - Second emission (
2):acc = 1 + 2 = 3. - Third emission (
3):acc = 3 + 3 = 6. - Source completes: Emit
6.
Edge Case: Incomplete Source Stream#
If the source observable never completes (e.g., a button click stream that runs indefinitely), reduce never emits:
import { fromEvent } from 'rxjs';
import { reduce } from 'rxjs/operators';
const button = document.getElementById('myButton');
const clicks = fromEvent(button, 'click'); // Infinite stream (never completes)
clicks.pipe(
reduce((count, _) => count + 1, 0) // Count clicks
).subscribe({ next: (count) => console.log('Total clicks:', count) }); // Never logs! Why? reduce waits for the source to complete, which never happens here.
How scan Works: A Deep Dive#
The scan operator is like reduce but emits after every source emission. It’s ideal for tracking intermediate state changes in real time. Think of it as "reduce with live updates."
Signature#
scan<T, R>(
accumulator: (acc: R, value: T, index: number) => R,
seed?: R
): OperatorFunction<T, R> The parameters are identical to reduce, but the behavior differs in emission timing.
How It Works: Step-by-Step#
-
Initialize the accumulator:
- If a
seedis provided, start withseed. - If no
seedis provided, emit the first source value as-is (no accumulation), then use it as the initial accumulator for subsequent values.
- If a
-
Process and emit:
- For each source emission, apply the
accumulatorfunction to update the accumulator. - Emit the updated accumulator immediately after processing.
- For each source emission, apply the
Example 1: Summing Numbers with scan#
Using the same [1, 2, 3] stream as before, but with scan:
import { of } from 'rxjs';
import { scan } from 'rxjs/operators';
const numberStream = of(1, 2, 3);
numberStream.pipe(
scan((acc, value) => acc + value, 0) // Seed = 0
).subscribe({ next: (sum) => console.log('Current sum:', sum) }); Output:
Current sum: 1 // After first emission (0+1=1)
Current sum: 3 // After second emission (1+2=3)
Current sum: 6 // After third emission (3+3=6)
Why? scan emits after each source value is processed, showing the running total.
Example 2: scan Without a Seed#
Without a seed, scan emits the first source value directly, then accumulates from the second:
const numberStream = of(1, 2, 3);
numberStream.pipe(
scan((acc, value) => acc + value) // No seed
).subscribe({ next: (sum) => console.log('Current sum:', sum) }); Output:
Current sum: 1 // First emission: emitted as-is (no accumulation)
Current sum: 3 // Second emission: 1 + 2 = 3
Current sum: 6 // Third emission: 3 + 3 = 6
Example 3: Real-Time Counter with scan#
Unlike reduce, scan works with infinite streams. Let’s fix the earlier button click counter:
import { fromEvent } from 'rxjs';
import { scan } from 'rxjs/operators';
const button = document.getElementById('myButton');
const clicks = fromEvent(button, 'click');
clicks.pipe(
scan((count, _) => count + 1, 0) // Count clicks
).subscribe({ next: (count) => console.log('Clicks so far:', count) }); Output (on each click):
Clicks so far: 1
Clicks so far: 2
Clicks so far: 3
...
Why? scan emits after every click, updating the count in real time.
Practical Examples: When to Use Each#
Let’s explore real-world scenarios where reduce and scan shine.
Example 1: Counter App#
scan: Update the UI with the current count after each increment/decrement.reduce: Calculate the total number of increments/decrements when the app closes.
import { fromEvent } from 'rxjs';
import { scan, takeUntil } from 'rxjs/operators';
const incrementBtn = document.getElementById('increment');
const decrementBtn = document.getElementById('decrement');
const closeBtn = document.getElementById('close');
const increments = fromEvent(incrementBtn, 'click').pipe(mapTo(1));
const decrements = fromEvent(decrementBtn, 'click').pipe(mapTo(-1));
const actions = increments.pipe(merge(decrements));
const close = fromEvent(closeBtn, 'click');
// Real-time counter (scan)
actions.pipe(
scan((count, delta) => count + delta, 0),
takeUntil(close) // Stop when app closes
).subscribe(count => updateUI(count)); // Updates UI on every click
// Total actions (reduce)
actions.pipe(
map(_ => 1), // Count each action as 1
reduce((total, _) => total + 1, 0),
takeUntil(close)
).subscribe(total => console.log('Total actions:', total)); // Logs when app closes Example 2: Tracking User Sessions#
scan: Track the user’s current session state (e.g., "active", "idle", "logged out") as events occur.reduce: Generate a final session report (total active time, number of actions) when the session ends.
Common Pitfalls and How to Avoid Them#
1. Forgetting reduce Requires Source Completion#
Pitfall: Using reduce on an infinite stream and wondering why no value emits.
Fix: Use scan for infinite streams, or force completion with take(n), takeUntil, etc.
2. Overusing scan for Final Results#
Pitfall: Using scan when only the final result is needed, leading to unnecessary emissions and performance overhead.
Fix: Use reduce for final aggregation to minimize emissions.
3. Seed Value Confusion#
Pitfall: Misunderstanding how the seed affects behavior (especially when omitted).
Rule of Thumb:
- Always provide a seed if you need predictable initial state (e.g.,
0for counters). - Omit the seed only if you want the first emission to pass through unmodified (rarely useful).
4. Memory Leaks with scan#
Pitfall: Using scan on an infinite stream with large accumulators (e.g., storing all historical events) can bloat memory.
Fix: Use throttleTime, debounceTime, or distinctUntilChanged to limit emissions, or reset the accumulator periodically.
Conclusion#
reduce and scan are powerful accumulation operators, but their use cases are distinct:
- Use
reducewhen you need a single final result (e.g., totals, averages) and the source stream completes. - Use
scanwhen you need intermediate state updates (e.g., real-time counters, progress tracking) or the source is infinite.
By choosing the right operator for the job, you’ll write cleaner, more efficient reactive code.
References#
- RXJS
reduceDocumentation - RXJS
scanDocumentation - Reactive Programming with RXJS (Book by Sergi Mansilla)
- Marble Diagrams for RXJS Operators (Visualize operator behavior)