Not all front-end developers have the same level of familiarity with RxJS. While some may not be aware of it or find it challenging to work with, many others, especially those working with Angular, utilize it frequently and effectively.
What’s remarkable is that RxJS can be used for state management in any front-end framework with surprising simplicity and effectiveness. This tutorial will focus on an RxJS/React approach, but the techniques demonstrated can be applied to other frameworks.
One thing to note is that RxJS code can sometimes be lengthy. To address this, I’ve created a utility library that offers a more concise syntax. However, for those who prefer a more purist approach, I’ll also explain how this library utilizes RxJS, allowing you to opt for the longer, non-utility approach if desired.
A Multi-app Case Study
My team and I were developing several TypeScript applications for a major client, using React and the following libraries:
- StencilJS: A framework designed for building custom web elements.
- LightningJS: A framework that leverages WebGL for creating animated applications.
- ThreeJS: A JavaScript library used to build 3D WebGL applications.
Since our apps shared a lot of the same state logic, I realized we needed a better way to manage state across the board. Ideally, this solution would be:
- Independent of any specific framework.
- Reusable across different projects.
- Compatible with TypeScript.
- Easy to grasp.
- Flexible enough to be expanded upon.
With these requirements in mind, I explored various options to determine the most suitable solution.
State Management Solution Options
I ruled out the following candidates based on how well they aligned with our specific needs:
Candidate | Notable Attributes | Reason for Rejection |
|---|---|---|
|
| |
|
| |
|
|
While examining RxJS and its capabilities—specifically its collection of operators, observables, and subjects—I recognized that it fulfilled all our criteria. All that remained to establish our reusable state management solution was a simple layer of utility code to streamline implementation.
A Brief Introduction to RxJS
RxJS has been in use since 2011 and enjoys widespread adoption, both as a standalone library and as the foundation for other libraries like Angular.
The most fundamental concept in RxJS is the Observable](https://rxjs.dev/guide/observable). It’s an object capable of emitting values whenever it wants, with subscribers receiving these updates. Similar to how the Promise object provided a standardized object representation for the [asynchronous callback pattern, the Observable serves as a standard for the observer pattern.
Note: In this article, I will use the convention of adding a $ sign at the end of observable variable names. For instance, data$ indicates an Observable.
| |
Crucially, you can piped an observable using an operator. Operators can modify the emitted values, the timing or quantity of emitted events, or even both.
| |
Observables come in various forms. For instance, in terms of their timing:
- They might emit a value only once at some point in the future, much like a promise.
- They could emit multiple values over time, similar to user click events.
- They might emit a value immediately upon subscription, as seen with the straightforward
offunction.
| |
It’s also important to understand that the events emitted by an observable may not be perceived identically by all subscribers. This is because observables are generally categorized as either cold or hot. Cold observables are akin to viewers streaming a show on Netflix—each watches at their own pace, receiving their own individual stream of events:
| |
On the other hand, hot observables are like people watching a live football match—everyone sees the same events unfold simultaneously. Similarly, every subscriber to a hot observable receives events concurrently:
| |
RxJS offers a rich set of capabilities, and it’s understandable if newcomers feel overwhelmed by the intricacies of observers, operators, subjects, schedulers, as well as multicast, unicast, finite, and infinite observables.
The good news is that we only need a small subset of RxJS—stateful observables—for state management, as I’ll explain next.
RxJS Stateful Observables
So, what exactly are stateful observables?
Firstly, these observables maintain a concept of a current value. Subscribers receive values synchronously, even before the next line of code executes:
| |
Secondly, they emit an event whenever their value updates, ensuring all subscribers are informed. Moreover, they are hot observables, meaning these updates are received simultaneously by all subscribers.
Managing State with the BehaviorSubject Observable
RxJS’s BehaviorSubject embodies the characteristics of a stateful observable. It acts as a wrapper around a value, emitting an event containing the new value whenever that value changes:
| |
This seems like a good fit for managing our state, and the code functions seamlessly with any data type. To make this even more suitable for single-page applications, we can leverage RxJS operators for enhanced efficiency.
Enhancing Efficiency with the distinctUntilChanged Operator
Ideally, when managing state, we want our observables to emit only distinct values. This means that if the same value is set multiple times, only the first instance triggers an emission. This is crucial for maintaining performance in single-page applications and can be accomplished using the distinctUntilChanged operator:
| |
Combining BehaviorSubject with distinctUntilChanged provides a powerful mechanism for holding and managing state. Next, let’s address how to handle derived state.
Derived State with the combineLatest Function
Derived state is a key concept in single-page application state management. It refers to state that’s derived from other pieces of state. For instance, a full name might be derived from separate first and last name values.
In RxJS, we can achieve this using the combineLatest function in conjunction with the map operator:
| |
However, the calculation involved in deriving state (the logic within the map function above) can be computationally expensive. Instead of repeating this calculation for each observer, it would be more efficient to perform it once, cache the result, and share it among observers.
Fortunately, this is easily accomplished by piping the result through the shareReplay operator. We’ll also reintroduce distinctUntilChanged to prevent unnecessary notifications if the derived state remains unchanged:
| |
We’ve now seen that BehaviorSubject paired with the distinctUntilChanged operator effectively handles state, while combineLatest, piped through map, shareReplay, and distinctUntilChanged, excels at managing derived state.
However, as a project grows, repeatedly writing these combinations of observables and operators becomes cumbersome. To simplify this, I developed a small library that offers a convenient wrapper around these concepts.
The rx-state Convenience Library
Instead of duplicating the same RxJS code throughout our projects, I created a lightweight, open-source convenience library called rx-state. This library acts as a handy wrapper around the RxJS elements we’ve discussed.
RxJS observables are somewhat limited by their need to share an interface with non-stateful observables. rx-state, however, provides convenient methods like getters, which become quite useful when we’re solely focused on stateful observables.
The core of this library revolves around two key components: the atom, used to hold state, and the combine function, responsible for handling derived state:
Concept | RxJs | rx-state |
|---|---|---|
Holding State |
|
|
Derived State |
|
|
Think of an atom as a wrapper around any piece of state—be it a string, number, boolean, array, object, or any other data type—that transforms it into an observable. It provides get, set, and subscribe as its primary methods, seamlessly integrating with RxJS.
| |
A comprehensive overview of the API can be found in the GitHub repository.
Derived state, created using the combine function, essentially behaves like a read-only atom:
| |
Note that atoms returned from combine lack a set method because their values are derived from other atoms or RxJS observables. Similar to the atom, the complete API documentation for combine is available in the GitHub repository.
Now equipped with a straightforward, efficient way to manage state, our next task is to encapsulate reusable logic that can be shared across applications and frameworks.
The good news is that we don’t need to introduce any more libraries. Plain old JavaScript classes, packaged as stores, are perfectly suited for encapsulating this reusable logic.
Reusable JavaScript Stores
Rather than adding complexity with additional library code, we can leverage the capabilities of a vanilla JavaScript class to encapsulate our state logic. (If you prefer a more functional approach, these principles are readily adaptable, given the same building blocks: atom and combine.)
Publicly accessible instance properties can expose our state, while public methods provide a means to update it. For example, let’s say we’re building a 2D game and need to keep track of a player’s position using x and y coordinates. Additionally, we want to know the player’s distance from the origin (0, 0):
| |
As this is a standard JavaScript class, we can employ the private and public keywords (supported by TypeScript and available as private class features in modern JavaScript) to define the desired interface.
It’s worth noting that there might be scenarios where you prefer the exposed atoms to be read-only:
| |
For these situations, rx-state provides a dedicated couple of options.
While this example is fairly basic, it illustrates the fundamentals of state management. Comparing our approach to a popular solution like Redux:
- Atoms replace the concept of a store in Redux.
- We utilize
combineto manage derived state, a task often handled by libraries like Reselect in the Redux world. - Plain JavaScript class methods take the place of Redux’s actions and action creators.
Importantly, since our stores are plain JavaScript classes with no external dependencies, they can be easily packaged and reused across different applications, even those built with different frameworks. Let’s see how we can integrate them into React.
React Integration
Integrating a stateful observable into React is straightforward using the useState and useEffect hooks:
| |
Continuing with our player example, we can then unwrap our observables into their raw values:
| |
Similar to the rx-state library, I’ve packaged the useWrap hook, additional functionality, TypeScript support, and a few more utility hooks into a dedicated small rx-react library available on GitHub.
A Note on Svelte Integration
If you’re familiar with Svelte, you’ve probably noticed the resemblance between atoms and Svelte stores. In this article, I use the term “store” to refer to a higher-level concept that brings together atom building blocks. In contrast, a Svelte store aligns more closely with an atom. Despite this distinction, their functionalities are strikingly similar.
If your work solely involves Svelte, you can opt for Svelte stores instead of atoms (unless you want to leverage RxJS operators through the pipe method). One of Svelte’s convenient features is that any object implementing a particular contract can have a $ prefix for automatic unwrapping into its raw value.
RxJS observables, after being support updates, inherently satisfy this contract. Our atom objects do as well. This allows our reactive state to seamlessly integrate with Svelte, behaving just like a native Svelte store.
Smooth React State Management With RxJS
RxJS is well-equipped to handle state management in JavaScript single-page applications. Here’s a recap:
- The
BehaviorSubjectcombined with thedistinctUntilChangedoperator forms a solid foundation for holding state. - The
combineLatestfunction, along with themap,shareReplay, anddistinctUntilChangedoperators, provides a powerful mechanism for managing derived state.
However, manually working with these operators can become tedious, which is where rx-state’s helper functions, atom and combine, prove invaluable. By encapsulating these building blocks within plain JavaScript classes and utilizing the language’s public/private access modifiers, we can construct reusable state logic.
Finally, achieving seamless state management in React is straightforward using hooks and the rx-react helper library. Integration with other libraries is often even simpler, as demonstrated with Svelte.
The Future of Observables
As for future developments, I believe the following updates would greatly benefit the world of observables:
- Special consideration for the synchronous subset of RxJS observables (those with a concept of a current value, such as
BehaviorSubjectand observables resulting fromcombineLatest). For example, perhaps they could all implement agetValue()method alongside the standardsubscribemethod.BehaviorSubjectalready does this, but other synchronous observables don’t. - Native support for JavaScript observables, an existing proposal that’s currently in progress.
These enhancements would bring greater clarity to the distinction between different types of observables, simplify state management even further, and unlock even greater capabilities within the JavaScript language.
The editorial team of the Toptal Engineering Blog would like to thank Baldeep Singh and Martin Indzhov for their diligent review of the code samples and technical content presented in this article.