Hello! Welcome to the fourth module of our course, "RxJS in the React Ecosystem."
In the previous modules, we built a solid foundation in RxJS, covering everything from Observables and operators to the powerful concepts of Subjects and multicasting with shareReplay. Now, we'll shift our focus to the practical application of these tools within the framework you use every day: React.
This lesson addresses a crucial first step: understanding the inherent challenges of this integration. By the end of our session, you'll be able to explain the two primary difficulties of using Observables with React's component lifecycle: memory leaks and a subtle but critical issue in modern React called "tearing."
Let's begin by exploring the most common and immediate challenge.
Challenge 1: Memory Leaks and the Component Lifecycle
In React, components have a distinct lifecycle: they are created and "mounted" into the DOM, and later, they are "unmounted" and destroyed. An Observable subscription, however, doesn't inherently know anything about this lifecycle.
When you subscribe to an Observable inside a component, that subscription will remain active, receiving notifications and consuming memory, even after the component has been removed from the UI. If the subscription's callback tries to update the state of an unmounted component, it will trigger an error. More insidiously, if the Observable is long-lived (like one created with interval or one connected to a WebSocket), it will continue running in the background, creating a memory leak.
This is a fundamental difference from Promises, which resolve or reject once and are then garbage collected. Observables can emit values indefinitely, so their lifecycle must be managed manually.
To see this problem illustrated, please read the first section of the following article. While the code examples use Angular, the core problem is identical in React.
Dealing with memory leaks in ReactiveX
This section from the article 'Dealing with memory leaks in ReactiveX' on marsbased.com clearly explains why unmanaged Observable subscriptions are problematic in component-based frameworks.
Please read the section titled 'The problem'. Pay attention to the explanation of why an Observable subscription differs from a Promise and how it can persist after a component is destroyed.
Translating to React
The article's ngOnInit lifecycle hook is analogous to the main function body of a useEffect hook in React, which runs after the component mounts. The ngOnDestroy hook is equivalent to the cleanup function returned from useEffect.
Here is what a leaky subscription looks like in a React component:
import { useEffect, useState } from 'react';
import { interval } from 'rxjs';
function LeakyCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// This subscription starts when the component mounts...
const subscription = interval(1000).subscribe(value => {
console.log('Interval fired:', value);
// This will try to update state even if the component is gone!
setCount(c => c + 1);
});
// ...but we never clean it up.
// The interval() will run forever.
}, []);
return <div>Count: {count}</div>;
}
If you were to mount and then unmount this component, the console.log would continue to fire every second, and React would warn you about trying to update the state on an unmounted component.
Solving the Leak
The solution is to tie the subscription's lifecycle to the component's lifecycle. We must unsubscribe when the component unmounts. The article you just read discusses several patterns for this.
Dealing with memory leaks in ReactiveX
Now, let's look at the common patterns for solving this memory leak, again from the same article.
Please read the section titled 'How to solve it'. Focus on the manual unsubscribe pattern and the more scalable takeUntil operator pattern.
The manual approach, translated to our React example, is straightforward:
useEffect(() => {
const subscription = interval(1000).subscribe(setCount);
// The cleanup function is called when the component unmounts
return () => {
console.log('Cleaning up subscription!');
subscription.unsubscribe();
};
}, []);
This pattern works perfectly well, but as the article notes, managing many subscriptions manually can become cumbersome. The takeUntil pattern is a more declarative and scalable RxJS solution, which we will implement in later lessons.
This need for careful subscription management is a recurring theme. You may recall from our previous module that misconfiguring shareReplay can also lead to a subscription that never terminates. In that case, the refCount: true option ties the source subscription's life to its observers. Here, we are tying the subscription's life to a React component. The principle is the same: a subscription's lifecycle must be explicitly managed.
Challenge 2: "Tearing" in Concurrent React
The second challenge is more subtle and is specific to modern versions of React (18+). It arises from a powerful feature called concurrent rendering. Before we can understand the problem, we need to understand what concurrent rendering is and why it exists.
The following video gives an excellent, high-level overview of the history and motivation behind this change in React's architecture.
The Story of Concurrent Rendering in React
This video, 'The Story of Concurrent Rendering in React' by ui.dev, explains why React moved from a synchronous to a concurrent rendering model. This context is essential for understanding 'tearing'.
Please watch from the beginning until the timestamp 06:16. Focus on the core ideas: synchronous rendering can block the main thread, and concurrent rendering allows React to pause rendering work to handle higher-priority tasks (like user input).
As the video explains, concurrent rendering allows React to start rendering an update, pause it, do something else, and then resume. This is fantastic for keeping the UI responsive. However, it creates a new problem when dealing with external data sources, like an RxJS-based store.
This problem is called "tearing."
Imagine this scenario:
- A user action triggers a state update in your RxJS store.
- React begins re-rendering your component tree. A
<Header>component reads the valueAfrom the store. - React pauses the render to handle a high-priority browser event.
- While paused, the external RxJS store emits a new value,
B. - React resumes rendering. A
<Footer>component, rendered after the pause, now reads the valueBfrom the store. - React commits the final UI to the screen. The result is a "torn" UI, where the
<Header>shows a state based on valueAand the<Footer>shows a state based on valueB. This is an inconsistent and buggy state.
The standard useEffect + useState pattern for subscribing to external data is vulnerable to tearing. The following article explains this problem in detail.
useSyncExternalStore: Demystified for Practical React ...
This article by Kent C. Dodds, a prominent figure in the React community, provides a definitive explanation of tearing and why the old patterns are no longer sufficient in the era of concurrent React.
Please read the sections 'Why does useSyncExternalStore exist? The Problem of "Tearing"' and 'Why not just use useEffect + useState?'. These sections directly address the core issue.
The key takeaway is that for an external store to be safe in concurrent React, reads from the store must be synchronous and consistent for the entire duration of a single render pass. The useEffect hook, which runs after rendering, cannot provide this guarantee.
Conclusion
In this lesson, we've identified the two fundamental challenges of integrating RxJS with React:
- Memory Leaks: Observable subscriptions are independent of the React component lifecycle. They must be manually unsubscribed when a component unmounts to prevent memory leaks and errors.
- UI Tearing: React's concurrent rendering can pause and resume renders. If an external RxJS store updates during this pause, it can lead to an inconsistent UI, where different parts of the application reflect different states.
Mastering RxJS in React means having robust solutions for both of these problems.
Next Steps
In our next lesson, we will put this theory into practice. We'll start by building a basic subscription hook using useEffect and useState to see firsthand how these pitfalls manifest. Then, we will refactor it into a "tear-free," reusable hook using the modern useSyncExternalStore API, which was designed specifically to solve the problem of tearing.
Can't find a good explanation? Sign up and we'll make it for you
Sign up