RxJS provides dozens of built-in operators, but sometimes you need specialized functionality for your specific use cases. Creating custom operators allows you to encapsulate complex logic, improve code reusability, and make your reactive streams more readable.
In this notebook, we'll explore different ways to build custom operators in RxJS, from simple composition to creating operators from scratch.
$ npm install rxjs added 2 packages in 2s 28 packages are looking for funding run `npm fund` for details
RxJS loaded successfully
Before diving into the how, let's understand why you might want to create custom operators:
The simplest way to create a custom operator is by composing existing operators. This approach requires no special knowledge beyond basic pipe usage.
Using our multiplyAndRound custom operator: Result: 12 Result: 25 Result: 37 Result: 49
The pattern is straightforward:
pipe()
to apply existing operators inside your custom operatorLet's look at a more practical example:
Testing retryWithExponentialBackoff: API call attempt 1 Retry attempt 1 after 100ms API call attempt 2 Retry attempt 2 after 200ms API call attempt 3 API call successful! Operation completed successfully
This exponential backoff retry operator encapsulates a fairly complex error handling strategy, but now we can reuse it easily across our application with just one line of code.
Let's create a simple but useful debugging operator that helps trace the flow of events through your RxJS streams.
Testing our debug operator: [2025-03-14T13:53:41.366Z] [SOURCE] NEXT (number): 1 [2025-03-14T13:53:41.366Z] [AFTER MAP] NEXT (number): 10 [2025-03-14T13:53:41.366Z] [AFTER FILTER] NEXT (number): 10 [2025-03-14T13:53:41.366Z] [SOURCE] NEXT (number): 2 [2025-03-14T13:53:41.366Z] [AFTER MAP] NEXT (number): 20 [2025-03-14T13:53:41.366Z] [AFTER FILTER] NEXT (number): 20 [2025-03-14T13:53:41.366Z] [SOURCE] NEXT (number): 3 [2025-03-14T13:53:41.366Z] [AFTER MAP] NEXT (number): 30 [2025-03-14T13:53:41.366Z] [AFTER FILTER] NEXT (number): 30 [2025-03-14T13:53:41.366Z] [SOURCE] NEXT (object): {"name":"John"} [2025-03-14T13:53:41.366Z] [AFTER MAP] NEXT (object): {"name":"John"} [SOURCE] COMPLETE [AFTER MAP] COMPLETE [AFTER FILTER] COMPLETE
This debug operator is immensely helpful during development to understand how data flows through your streams and identify where issues might be occurring.
Let's create some operators that are specific to a common domain problem: handling API responses.
Testing API custom operators: Successful API call: State: {"loading":true,"data":null,"error":null} State: {"loading":false,"data":{"id":123,"name":"Example data"},"error":null} Success case completed Failed API call: State: {"loading":true,"data":null,"error":null} State: {"loading":false,"data":null,"error":"Failed to fetch data"} Error case completed
These domain-specific operators help manage API interaction patterns that are common in web applications. By encapsulating the loading state and error handling logic into reusable operators, we make our application code more declarative and consistent.
Sometimes you need complete control over how your custom operator works. In this case, you'll need to create an operator from scratch by working directly with the Observable.
Testing custom buffer toggle operator: Source emitted: 0 Source emitted: 1 Source emitted: 2 Source emitted: 3 Source emitted: 4 Source emitted: 5 Source emitted: 6 Buffer emitted: [4, 5, 6] Source emitted: 7 Source emitted: 8 Source emitted: 9 Source emitted: 10 Source emitted: 11 Buffer emitted: [9, 10, 11] Source emitted: 12 Source emitted: 13 Source emitted: 14 Source emitted: 15 Source emitted: 16 Buffer emitted: [14, 15, 16] Source emitted: 17 Source emitted: 18 Source emitted: 19 Custom buffer toggle example completed
This example creates a custom version of the bufferToggle operator from scratch. While this approach gives you complete control, it's also more complex and error-prone. Use it only when you can't achieve your goals by composing existing operators.
operate
Function (RxJS 7+)In newer versions of RxJS, there's a more convenient way to create custom operators from scratch using the operate
function, which simplifies the process.
Testing our custom distinctUntilKeyChanged operator: User: {"id":1,"name":"Alice","status":"online"} User: {"id":2,"name":"Bob","status":"offline"} User: {"id":1,"name":"Alice","status":"offline"} User: {"id":3,"name":"Charlie","status":"online"}
Here are some guidelines for deciding when to create custom operators and which approach to use:
Use composition (first approach) when:
Create from scratch when:
Use operate (RxJS 7+) when:
Here are some best practices to follow when creating custom operators:
Testing well-designed whereEqual operator: Developer: Alice, age: 25 Developer: Charlie, age: 35 Developer: Eve, age: 45
Let's create a few more practical custom operators that you might use in real applications.
Testing executeOnce operator: Without executeOnce (will execute twice): Expensive operation executed (count: 1) Expensive operation executed (count: 2) First subscriber: Expensive result Expensive operation cleanup Second subscriber: Expensive result Expensive operation cleanup With executeOnce (will execute only once): Expensive operation executed (count: 1) First subscriber: Expensive result Second subscriber: Expensive result Expensive operation cleanup
Testing debugWithLabel operator: [Raw names] Next: "Alice" [Uppercase names] Next: "ALICE" [Long names] Next: "ALICE" [Raw names] Next: "Bob" [Uppercase names] Next: "BOB" [Raw names] Next: "Charlie" [Uppercase names] Next: "CHARLIE" [Long names] Next: "CHARLIE" [Raw names] Complete [Uppercase names] Complete [Long names] Complete
Testing bufferUntilIdle operator: Typed: H Typed: H Typed: e Typed: e Typed: l Typed: l Typed: l Typed: l Typed: o Typed: o Buffered typing: [Hello] Typed: Typed: Typed: w Typed: w Typed: o Typed: o Typed: r Typed: r Typed: l Typed: l Typed: d Buffered typing: [ world] bufferUntilIdle example complete
A key advantage of custom operators is that they can be tested in isolation. RxJS provides marble testing utilities to make this easier.
Testing multiplyAndRound with marble testing: Actual: [ { "frame": 0, "value": 12 }, { "frame": 2, "value": 27 }, { "frame": 4, "value": 35 }, { "frame": 6, "value": "C" } ] Expected: [ { "frame": 0, "value": 12 }, { "frame": 2, "value": 27 }, { "frame": 4, "value": 35 }, { "frame": 6, "value": "C" } ] Test passed: true
Testing custom retry operator with marble testing: Retry attempt 1 after 10ms Retry attempt 2 after 20ms Actual: [ { "frame": 36, "value": "E" } ] Expected: [ { "frame": 6, "value": "a" }, { "frame": 8, "value": "C" } ] Test passed: false
When creating custom operators, follow these best practices to ensure they're reliable and maintainable:
Keep operators pure and focused: Each operator should do one thing well.
Handle all Observable lifecycle events: Properly handle next, error, and complete notifications.
Document with input/output marble diagrams: Visual documentation makes operators easier to understand.
Use existing operators when possible: Compose from built-in operators before creating from scratch.
Write thorough tests: Test both normal operation and edge cases.
Follow naming conventions:
Share your operators: If you've created something useful, consider sharing it with the community.
Beware of memory leaks: Ensure all subscriptions are properly cleaned up.