Hello! Welcome back to our course on RxJS in the React ecosystem.
In our last lesson, we built a subscription hook using useEffect and useState. We saw firsthand how this common pattern, while intuitive, introduces significant risks: memory leaks if cleanup is forgotten, and more subtly, a fundamental vulnerability to UI tearing in React's concurrent rendering model.
Today, we will address these issues head-on. The learning outcome for this lesson is to apply the useSyncExternalStore hook to create a tear-free, reusable useObservable hook for safe component subscriptions. This hook is React's official, modern solution for integrating external state sources like RxJS, ensuring your UI remains consistent and free from the subtle bugs we discussed.
Let's dive in and build the correct, robust bridge between RxJS and React.
1. Understanding useSyncExternalStore
Before we write any RxJS-specific code, we need to understand the tool React provides for this exact scenario. The useSyncExternalStore hook was introduced to provide a standardized, safe way to read from and subscribe to an external data source. It guarantees that all components in a single render pass will see the exact same value, thus preventing tearing.
To get a solid conceptual grasp of this hook, we'll watch a video that builds a simple external store using plain JavaScript. This will make the hook's requirements crystal clear before we apply them to RxJS.
The Most Underrated React Hook You've Never Used
The video 'The Most Underrated React Hook You've Never Used' by Cosden Solutions provides an excellent, ground-up explanation of why useSyncExternalStore is necessary and how it works.
Please watch the following segments: The Problem (03:32 - 09:05): This section demonstrates why normal variables or useRef don't trigger re-renders, reinforcing the need for a mechanism to notify React of external changes. Building the External Store (09:05 - 13:50): Pay close attention to the structure of the store. It's just a plain object with its own data, a way to add listeners (subscribe), and a way to get the current data (getSnapshot). Integration with React (13:50 - 15:55): See how the subscribe and getSnapshot functions are passed directly to useSyncExternalStore. How It Works (15:55 - 18:02): This is the most important part. It explains how React passes its own internal onStoreChange function to your subscribe method, allowing the external store to trigger a re-render safely.
2. The Anatomy of useSyncExternalStore
As the video explained, the hook's signature is useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?). Let's break down the two mandatory arguments in the context of what we need to build.
-
subscribe(onStoreChange):- This is a function that you provide. It receives a single argument,
onStoreChange, which is a callback function provided by React itself. - Your responsibility is to subscribe to your external store (e.g., our RxJS Observable) and, whenever the store's value changes, you must call the
onStoreChangecallback. - Crucially, this
subscribefunction must return another function that handles the cleanup (i.e., unsubscribing from the store). React will call this cleanup function when the component unmounts.
- This is a function that you provide. It receives a single argument,
-
getSnapshot():- This is another function you provide. Its job is to return a "snapshot" of the current data from the store.
- This function must be synchronous. React calls it to get the initial value and to check if the value has changed after the store notifies it of an update.
This pattern should sound familiar. The subscribe function with its cleanup return value is identical to the useEffect cleanup pattern, and it maps perfectly to an RxJS Observable.subscribe() call, which returns a Subscription object with an unsubscribe method.
3. Building a useObservable Hook
Now, let's translate this knowledge into a reusable custom hook that can subscribe to any RxJS Observable.
Attempt 1: The getSnapshot Challenge
Let's start with a basic implementation. The code snippet below shows a direct attempt at creating a useObservable hook.
Integrating with Observable Data Sources Like RxJS | Snippets
The article 'Integrating with Observable Data Sources Like RxJS' from Borstch provides a very direct, though slightly flawed, implementation of a useObservable hook.
Please review the useObservable function in this snippet. Notice how it defines subscribe and getSnapshot to pass to useSyncExternalStore.
Let's analyze the implementation from the snippet:
function useObservable(observable) {
const subscribe = (setState) => {
const subscription = observable.subscribe(setState);
return () => subscription.unsubscribe();
};
// The problem is here:
const getSnapshot = () => null;
// The hook returns the `state` which is managed internally by useSyncExternalStore
const state = useSyncExternalStore(subscribe, getSnapshot);
return state;
}
The subscribe function is perfect. It subscribes to the observable and returns the unsubscribe logic.
However, getSnapshot is problematic. It always returns null. A standard, "cold" Observable doesn't have a concept of a "current value" that you can fetch synchronously. It only pushes values over time. While this hook might appear to work (because the subscription will fire and update the state), it's not a robust implementation and goes against the design of useSyncExternalStore, which relies on being able to synchronously get the current value.
Attempt 2: The Correct Approach with BehaviorSubject
How can we provide a synchronous getSnapshot? The answer lies in using an Observable variant that does have a current value: the BehaviorSubject. As we've seen, a BehaviorSubject always maintains the most recent value it has emitted, which is accessible via the synchronous .getValue() method.
This makes BehaviorSubject a perfect fit for useSyncExternalStore.
Let's look at a more robust implementation that leverages this.
Using RxJS with React - Dimitrios Lytras
The article 'Using RxJS with React' by Dimitrios Lytras provides an implementation that correctly uses BehaviorSubject.
Read the section 'Without React-context' and focus on the useObservable hook implementation. Notice how it uses observableRef.current.getValue() for getSnapshot.
Inspired by that, we can write our own clean, reusable, and type-safe hook.
Here is the final, recommended implementation for your useObservable hook. This version is designed to work with BehaviorSubject (or any Observable with a getValue method).
// hooks/useObservable.ts
import { useSyncExternalStore, useCallback } from 'react';
import { BehaviorSubject } from 'rxjs';
export function useObservable<T>(observable$: BehaviorSubject<T>): T {
const subscribe = useCallback(
(onStoreChange: () => void) => {
// The subscription simply calls the React-provided callback on each emission.
const subscription = observable$.subscribe(onStoreChange);
// The returned function handles the cleanup.
return () => subscription.unsubscribe();
},
[observable$] // Re-subscribe if the observable instance itself changes.
);
const getSnapshot = useCallback(() => {
// This is the crucial part: synchronously get the current value.
return observable$.getValue();
}, [observable$]);
// Use the hook with our tailored functions.
return useSyncExternalStore(subscribe, getSnapshot);
}
4. Refactoring our Component
Now, let's see the payoff. We can refactor the AuthDisplay component from our previous lesson to use our new, tear-free hook.
Before (useEffect + useState):
// AuthDisplay.jsx (Old version)
import React, { useState, useEffect } from 'react';
import { authState$ } from './store'; // authState$ is a BehaviorSubject
export function AuthDisplay() {
const [auth, setAuth] = useState(authState$.getValue()); // Initial value
useEffect(() => {
const subscription = authState$.subscribe(state => {
setAuth(state);
});
return () => {
subscription.unsubscribe();
};
}, []); // Dependency array is tricky here
if (auth === null) return <div>Loading...</div>;
if (auth === false) return <div>User is not logged in.</div>;
return <div>Welcome, {auth.name}!</div>;
}
After (useObservable hook):
// AuthDisplay.jsx (New, improved version)
import React from 'react';
import { authState$ } from './store';
import { useObservable } from './hooks/useObservable';
export function AuthDisplay() {
// All the complex logic is now abstracted away!
const auth = useObservable(authState$);
if (auth === null) {
return <div>Loading...</div>;
}
if (auth === false) {
return <div>User is not logged in.</div>;
}
return <div>Welcome, {auth.name}!</div>;
}
The difference is stark. The component is now declarative and clean. It simply states, "my auth variable should be synchronized with the authState$ observable." All the imperative logic of subscribing, unsubscribing, and setting state is encapsulated in our robust, reusable, and tear-free useObservable hook.
Conclusion
In this lesson, we have successfully built the modern, correct bridge between RxJS and React.
Key Takeaways:
useSyncExternalStoreis React's canonical solution for subscribing to external state sources, preventing UI tearing in concurrent rendering.- It requires two functions: a
subscribefunction that manages the subscription lifecycle and agetSnapshotfunction that synchronously reads the store's current value. - While any RxJS
Observablefits thesubscribepattern,BehaviorSubjectis the ideal candidate because its.getValue()method perfectly fulfills the synchronousgetSnapshotrequirement. - By encapsulating this logic in a custom
useObservablehook, we create a powerful, declarative, and safe API for consuming RxJS streams within our React components.
You are now equipped with the fundamental pattern for safely integrating RxJS observables into a modern React application.
Next Steps
With a reliable way to get data out of RxJS streams and into our components, we can now focus on more advanced architectural patterns. In the next lesson, we will explore cross-component communication. We'll start with a simple event bus built with a Subject and then refactor it into a more scalable and type-safe solution, leveraging the useObservable hook we built today.
Can't find a good explanation? Sign up and we'll make it for you
Sign up