At FullStory, the playback team has recently been working to migrate our session replay UI from an in-house templating framework to React. React offers many advantages over our previous framework, including better code reuse, an improved development experience, and declarative programming paradigms.
However, React’s rendering lifecycle presents some unique challenges to building a performant session replay experience. In particular, it is difficult to optimize re-renders in an app that must update global state very frequently.
To solve this problem, we decided to leverage Observables.
What are Observables?
Observables are a programming concept used for dealing with asynchronous or "streaming" data. They represent a data source that emits values over time.
The example below defines an Observable and subscribes to it. The Observable pushes the value
”Hello” immediately (synchronously) when subscribed, pushes the
”, World!” value one second after the subscribe call, then completes:
This produces the following console output:
Observables may seem familiar to anyone who has used a Promise object’s
.then() method. An Observable object’s
.subscribe() method is similar because it also registers a callback to receive future values. However, there are key differences between Observables and Promises:
An Observable can produce multiple values over time.
An Observable does not execute until
Observables allow us to declare side effects eagerly and subscribe to value streams lazily. This fact may not seem compelling on its own, but the real power of Observables comes from operators, which allow us to express complex dataflow pipelines declaratively through composition.
What are Operators?
In addition to the
.subscribe() method, Observable objects also support a
.pipe() method that can be used to compose functionality using operator functions. The
.pipe() method takes one or more operator functions and returns a new Observable that passes each of its future values through the operators.
To understand pipe, let’s first imagine an Observable of playback pages:
With the pipe method and a few operator functions, we can transform our
page$ Observable into one that produces different values:
With just 3 lines of code, we’ve created a new
isOnline$ Observable that emits a Boolean value indicating if a user is online, ignores duplicates, and defaults to false. Pretty cool, right?
TIP: Did you know that
$ is a common suffix to denote an Observable?
But remember, an Observable doesn’t execute until
.subscribe() is called. Using the
.pipe() method returns a new Observable, but its values are not actually delivered until a Subscription is created via
.subscribe(). As a result, Observables allow us to express and compose logic declaratively.
What is RxJS?
While Observables are a general pattern supported in many languages, RxJS is a specific Observable library for TypeScript. Several Observable libraries exist for TypeScript, but RxJS is clearly the most popular. With over 46 million weekly downloads, it is the obvious choice for most web developers.
In addition to core Observable classes, RxJS offers hundreds of operators designed to express complex logic through simple composition. Using RxJS, it is trivial to build complex functionality from a set of primitive Observables.
Check out some of the great Operators RxJS has to offer if you’re curious!
How would you use Observables in React?
Using Observables in React is actually quite simple! Subscribing to an Observable is just a side effect, and like any other side effect, we can manage it using React’s powerful useEffect hook.
Of course, there are many other ways that we need to work with Observables, so it’s best to use a dedicated library to integrate them with React.
At FullStory, we use a lightly forked version of the observable-hooks library to integrate Observables into our apps. Using observable-hooks, the previous example becomes even simpler!
Okay, but why would you use Observables in React?
Earlier, I alluded to some of the unique performance challenges in FullStory’s session replay app. To elaborate, almost every React component needs to derive some state from the current playback time.
Unfortunately, the playback time changes often. To maintain video-like fidelity, the time must be updated on every animation frame (yes, FullStory captures a ton of events). In a poorly optimized app, every React component would re-render on every animation frame.
Luckily, React has good tools for dealing with this type of problem! I’m sure seasoned React developers are already wondering, “Why not store a time ref in a shareable context?” This is a great question!
It would certainly be easy enough to use React’s context:
This works well for reading the time, but how would we derive values from it? Ideally, we want our components to re-evaluate derived values whenever the time changes, but re-render only when the resulting value is different.
So, what do we do?
One simple approach might be to define a push-based subscription mechanism on our context object. One can imagine adding a
subscribe() function that allows components to register a callback that is invoked when the time changes.
But wait, isn’t this approach…similar to an Observable?
Observables offer an elegant solution to our problem
Indeed, our rendering problem is more efficiently solved using Observables than refs alone. Instead of exposing a ref on our context object, we can expose an Observable for the current time!
Individual components can then use the
.pipe() operator to derive new Observables from the time and subscribe to their values lazily.
For example, imagine we need to re-calculate the current page URL whenever the time changes, but we only want to re-render if the resulting value has changed.
Using RxJS operators, this is easy:
With only a few lines of code, we’ve transformed the current time into a new Observable that will only emit values when the current page URL changes! This pattern works well across many session replay components.
What about Redux?
When discussing push-based state containers, redux is obviously the elephant in the room. It’s certainly true that a global redux store, combined with carefully memoized selectors, could be used to solve our re-rendering problem in a similar manner to Observables.
However, Observables, and RxJS in particular, offer a much richer set of operators to compose complex functionality than redux alone. While it’s trivial to implement a redux store with RxJS, it’s actually quite complex to implement many RxJS operators as redux selectors!
In session replay, RxJS operators are critical to our ability to express some of our most complex replay logic. In other apps, redux may be a better fit!
At FullStory, we use Observables in session replay to solve specific performance problems and express complex logic declaratively. Observables are an important tool to our team, but it’s also important that we know when not to use them.
As software engineers, we should always strive to use the right tool for the problem at hand. For many React apps, Observables could simply be distracting or unnecessarily complex. Redux, or even simple React state, might be a better fit.
Before choosing to use Observables, consider the specific problems you want to solve. If you are struggling to express complex functionality based on frequently changing values, perhaps Observables are the right tool for you!