Hello! Welcome to the final lesson of the "Real-World Architectural Patterns" module, and indeed, the final lesson of our course.
In our last session, we built a fascinating and complex piece of reactive logic: a model for a collaborative editor using merge and scan. That observable chain, combining local and remote data sources to manage a shared state, is a perfect example of the power of RxJS. It's also a perfect example of something that can be challenging to debug when it doesn't work as expected.
Today, we will address that challenge head-on. Our learning outcome is to debug complex Observable chains using advanced techniques like custom logging operators and RxJS DevTools. We'll move beyond simple console.log statements and explore a more robust and insightful debugging workflow.
This lesson will cover:
- The fundamental technique of inspecting streams with the
tapoperator. - How to create a reusable, custom
logoperator to improve readability and efficiency. - A deeper mental model of how operators work by understanding the "upward subscription flow."
- Using the RxJS DevTools browser extension for powerful visual debugging.
Let's get started.
1. The Challenge: Peeking Inside the "Black Box"
An RxJS pipe with multiple operators can sometimes feel like a black box. You know what goes in at the source and what should come out of the subscription, but if the output is wrong, where did the problem occur?
Common issues include:
- Data Transformation Errors: A
mapoperator might produce an unexpected value ornull. - Incorrect Filtering: A
filtermight be removing values you expected to see. - Concurrency Issues: A
switchMapmight be canceling a subscription you didn't intend to cancel. - Silent Errors: An error can terminate a stream silently if not handled with
catchError.
To solve these, we need ways to inspect the stream at intermediate points.
2. Basic Inspection with tap and Browser DevTools
The most direct way to see what's happening inside a pipe is with the tap operator. It allows you to perform side effects—like logging—for every emission, error, or completion without affecting the stream itself.
Let's take our collaborative editor code from the last lesson and add some tap operators to see the data flow.
import { Subject, interval, merge } from 'rxjs';
import { map, scan, startWith, tap } from 'rxjs/operators';
// ... (localChange$ and remoteUpdate$ definitions are the same)
const localChange$ = localUpdate$.pipe(
map(content => ({ source: 'local', content }))
);
const remoteUpdate$ = interval(4000).pipe(
map(i => ({ source: 'remote', content: ` [remote edit #${i + 1}] ` }))
);
const allChanges$ = merge(
localChange$.pipe(tap(change => console.log('LOCAL change:', change))),
remoteUpdate$.pipe(tap(change => console.log('REMOTE change:', change)))
);
const initialDocument = "Start typing...";
const documentState$ = allChanges$.pipe(
tap(change => console.log('Before scan:', change)),
scan((currentDoc, change) => {
// ... (scan logic is the same)
}, initialDocument),
tap(newState => console.log('After scan (new state):', newState)),
startWith(initialDocument)
);
documentState$.subscribe(doc => {
// UI would render the document
});
This is a good first step. The article "How to debug RxJs code with Angular?" provides a concise explanation of this technique.
How to debug RxJs code with Angular?
Please read this section from 'How to debug RxJs code with Angular?' by Devox Software. It reinforces the use of tap for basic logging.
Read section '2. Using Console Logging'. It provides a clear, simple example of using tap to log values before and after a transformation, which is exactly what we're doing here.
While logging is useful, you can also use the browser's built-in debugger. By placing a debugger statement inside a tap, you can pause execution and inspect the entire call stack and all variables in scope.
// ...
.pipe(
tap(value => {
console.log('Pausing here:', value);
debugger; // Execution will pause here if DevTools is open
}),
// ...
)
The same article you just read briefly covers this in section 3, "Using DevTools to Debug Asynchronous Code."
3. Advanced Technique: A Custom Logging Operator
Writing tap(v => console.log('message', v)) repeatedly is verbose and clutters your pipes. A much cleaner approach, common in professional codebases, is to create a custom logging operator.
The goal is to transform this:tap(e => console.log('After merge:', e))
Into this:log('After merge')
Let's explore how to build this.
Build your own RxJS logging operator
The article 'Build your own RxJS logging operator' by Angular.Schule is an excellent guide. It walks through several ways to create a custom operator, from the most fundamental to the most concise.
Please read the following sections: 'Building a log() operator' to understand the motivation. 'What are RxJS operators?' for the basic definition. '2) Use existing operators' and '3) Wrap existing operator into closure' to see the most practical ways to build our log operator. 'Which way is the best?' for guidance on when to use each approach. Focus on how a custom operator is just a function that can be built by composing existing operators like tap.
As the article demonstrates, the most elegant way to create our log operator is by simply returning a configured tap operator.
Here is a complete implementation you can use in your projects:
import { tap } from 'rxjs/operators';
import { MonoTypeOperatorFunction, Observable } from 'rxjs';
export function log<T>(
message?: string,
logFn: (...args: any[]) => void = console.log
): MonoTypeOperatorFunction<T> {
return (source: Observable<T>) =>
source.pipe(
tap({
next: value => logFn(`[NEXT] ${message || ''}`, value),
error: error => logFn(`[ERROR] ${message || ''}`, error),
complete: () => logFn(`[COMPLETE] ${message || ''}`),
})
);
}
This version is slightly more advanced: it logs not just next emissions but also error and complete notifications, giving you a full picture of the stream's lifecycle.
Our debugging example now becomes much cleaner:
const allChanges$ = merge(
localChange$.pipe(log('LOCAL')),
remoteUpdate$.pipe(log('REMOTE'))
);
const documentState$ = allChanges$.pipe(
log('Before scan'),
scan(/* ... */),
log('After scan'),
startWith(initialDocument)
);
Creating custom operators isn't just for logging. It's a powerful pattern for making any complex pipe more readable, reusable, and, importantly, testable. A custom operator is a standalone function that can be unit-tested in isolation, which dramatically simplifies debugging.
Custom RxJS Operators are Standing By! Act Now! | Chris Perko | ng-conf 2023
This video, 'Custom RxJS Operators are Standing By!', explains the benefits of abstracting logic into custom operators, particularly for readability and testing.
Watch from the beginning until 06:06. Pay close attention to: The 'lame code vs. sane code' comparison (01:28 - 02:25). The explanation of how to build custom operators by composing existing ones (02:25 - 04:52). The key argument about how custom operators are easier to unit test, which simplifies debugging (04:52 - 06:06).
4. Advanced Mental Model: The Upward Subscription Flow
Sometimes, the order of operators causes unexpected behavior, especially with timing. The common mental model of "data flowing down the pipe" is intuitive but incomplete. The real secret to understanding RxJS execution is that subscriptions flow up the pipe.
When you call .subscribe() on an observable chain, you are subscribing to the observable returned by the last operator. That operator then subscribes to the one before it, and so on, all the way back to the original source.
This concept is critical for debugging. Let's watch a video that explains it perfectly.
The secret to understanding piped operators in RxJS (Advanced)
Joshua Morony's video 'The secret to understanding piped operators in RxJS' provides the crucial 'aha!' moment for many developers. It explains the concept of upward subscription flow.
Please watch from the beginning until 08:18. Focus on: The initial explanation of data flow vs. subscription flow (00:00 - 02:47). The detailed breakdown of how subscriptions propagate upwards (02:47 - 05:32). The practical example with startWith and delay that shows why this model is essential for debugging operator order issues (05:32 - 08:18).
Understanding this upward flow helps you reason about why, for example, delay(1000).pipe(startWith(0)) behaves differently from startWith(0).pipe(delay(1000)). In the first case, startWith subscribes to a delayed source. In the second, the delay operator subscribes to a source that emits immediately, delaying all its emissions.
5. Advanced Tooling: RxJS DevTools
For the ultimate debugging experience, you can use a dedicated tool that visualizes your streams. RxJS DevTools is a browser extension that gives you a "mission control" view of all the observables in your application.
Key features include:
- Stream Visualization: See all active streams and their operator chains as marble diagrams.
- Value Inspection: Click on a marble to inspect the value that was emitted.
- Subscription Tracking: Identify active subscriptions to hunt down potential memory leaks.
- Error Highlighting: Immediately see which stream errored and inspect the error.
How to debug RxJs code with Angular?
Let's return to the 'How to debug RxJs code with Angular?' article for an overview of this powerful tool.
Read section '4. RxJS DevTools'. It covers installation and the main features: thread visualization, subscription tracking, and error search. This will give you a solid understanding of what the tool can do.
To use it, you typically install the browser extension and a small companion library (rxjs-devtools) in your project. Then, you connect your app to the tool. Once connected, you can open the RxJS tab in your browser's developer tools and watch your streams come to life. This visual feedback is invaluable for understanding complex interactions, especially those involving higher-order operators like switchMap or mergeMap.
A Practical Debugging Workflow
Here is a summary workflow you can use when tackling a buggy observable chain:
- Isolate and Observe: Sprinkle your custom
log()operator at key points in the chain to get a quick text-based trace of the data flow, errors, and completions. - Pause and Inspect: If logging reveals an anomaly, place a
debuggerstatement inside atapat that point. This lets you pause execution and use your browser's standard debugger to inspect the call stack and variable scope. - Reason and Refactor: If the issue seems to be related to timing or operator order, apply the "upward subscription flow" mental model to reason about the execution. This might lead you to reorder your operators.
- Visualize and Analyze: For the most complex chains, or to hunt for memory leaks, fire up RxJS DevTools. Get a high-level visual overview of the entire stream lifecycle to spot unexpected behavior.
Conclusion and Course Wrap-up
Congratulations! You have reached the end of our deep dive into RxJS. Today, you've added a professional-grade debugging toolkit to your skills, learning how to create custom logging operators and use advanced tools like RxJS DevTools. More importantly, you've deepened your mental model of how RxJS works under the hood.
Key Takeaways from this Lesson:
tapis the basic tool for inspecting streams via side effects.- Custom operators (like a
logoperator) make debugging cleaner, more readable, and promote reusable, testable code. - Thinking in terms of "subscriptions flowing up" is the key to solving complex operator ordering and timing issues.
- RxJS DevTools provides invaluable visual insight into stream behavior, helping you quickly identify problems in complex applications.
This lesson concludes not only the module on real-world patterns but our entire course. You've journeyed from the foundational Observable contract to advanced state management patterns and debugging techniques. You now have a robust framework for thinking reactively and the practical skills to apply RxJS effectively in a modern React environment, even integrating it with other state management libraries like Zustand.
The world of reactive programming is vast, but you have built a very strong foundation. The next step is to apply these patterns in your own projects. I encourage you to revisit these lessons as you encounter new challenges. Thank you for your commitment to this learning journey. It has been a pleasure being your tutor.
Can't find a good explanation? Sign up and we'll make it for you
Sign up