Reusable State Management With RxJS, React, and Custom Libraries
Discover a powerful RxJS and React approach that adds state management to single-page applications. RxJS provides an elegant, succinct, and comfortable implementation for developers that becomes more effective with custom libraries.
Discover a powerful RxJS and React approach that adds state management to single-page applications. RxJS provides an elegant, succinct, and comfortable implementation for developers that becomes more effective with custom libraries.
Mark is a full-stack software engineer who has written applications for major service companies including Intel, Disney, Sky, and Vodafone. He has led React and RxJS teams from prototype through commercial release, and holds master’s degrees in physics from Oxford University and King’s College.
Expertise
PREVIOUSLY AT
Not all front-end developers are on the same page when it comes to RxJS. At one end of the spectrum are those who either don’t know about or struggle to use RxJS. At the other end are the many developers (particularly Angular engineers) who use RxJS regularly and successfully.
RxJS can be used for state management with any front-end framework in a surprisingly simple and powerful way. This tutorial will present an RxJS/React approach, but the techniques showcased are transferable to other frameworks.
One caveat: RxJS can be verbose. To counter that I have assembled a utility library to provide a shorthand—but I will also explain how this utility library uses RxJS so that purists may chose the longer, non-utility path.
A Multi-app Case Study
On a major client project, my team and I wrote several TypeScript applications using React and these additional libraries:
- StencilJS: A framework for writing custom web elements
- LightningJS: A WebGL-based framework for writing animated apps
- ThreeJS: A JavaScript library for writing 3D WebGL apps
Since we used similar state logic across our apps, I felt the project would benefit from a more robust state management solution. Specifically, I thought we needed a solution that was:
- Framework-agnostic.
- Reusable.
- TypeScript-compatible.
- Simple to understand.
- Extensible.
Based on these needs, I explored various options to find the best fit.
State Management Solution Options
I eliminated the following solution candidates, based on their various attributes as they related to our requirements:
Candidate | Notable Attributes | Reason for Rejection |
---|---|---|
|
| |
|
| |
|
|
When I reviewed RxJS and noted its collection of operators, observables, and subjects, I realized that it checked every box. To build the foundation for our reusable state management solution with RxJS, I just needed to provide a thin layer of utility code for smoother implementation.
A Brief Introduction to RxJS
RxJS has been around since 2011 and is widely used, both on its own and as the basis for a number of other libraries, such as Angular.
The most important concept in RxJS is the Observable, which is an object that can emit values at any time, with subscribers following updates. Just as the introduction of the Promise object standardized the asynchronous callback pattern into an object, the Observable standardizes the observer pattern.
Note: In this article, I'll adopt the convention of suffixing observables with a $
sign, so a variable like data$
means it’s an Observable
.
// A Simple Observable Example
import { interval } from "rxjs";
const seconds$ = interval(1000); // seconds$ is an Observable
seconds$.subscribe((n) => console.log(`${n + 1} seconds have passed!`));
// Console logs:
// "1 seconds have passed!"
// "2 seconds have passed!"
// "3 seconds have passed!"
// ...
In particular, an observable can be piped through an operator, which could change either the values emitted, the timing/number of emitted events, or both.
// An Observable Example With an Operator
import { interval, map } from "rxjs";
const secsSquared$ = interval(1000).pipe(map(s => s*s));
secsSquared$.subscribe(console.log);
// Console logs:
// 0
// 1
// 4
// 9
// ...
Observables come in all shapes and sizes. For example, in terms of timing, they could:
- Emit once at some point in the future, like a promise.
- Emit multiple times in the future, like user click events.
- Emit once as soon as they’re subscribed to, as in the trivial
of
function.
// Emits once
const data$ = fromFetch("https://api.eggs.com/eggs?type=fried");
// Emits multiple times
const clicks$ = fromEvent(document, "click");
// Emits once when subscribed to
const four$ = of(4);
four$.subscribe((n) => console.log(n)); // logs 4 immediately
The events emitted may or may not appear the same to each subscriber. Observables are generally thought of as either cold or hot observables. Cold observables operate like people streaming a show on Netflix who watch it in their own time; each observer gets their own set of events:
// Cold Observable Example
const seconds$ = interval(1000);
// Alice
seconds$.subscribe((n) => console.log(`Alice: ${n + 1}`));
// Bob subscribes after 5 seconds
setTimeout(() =>
seconds$.subscribe((n) => console.log(`Bob: ${n + 1}`))
, 5000);
/* Console starts from 1 again for Bob */
// ...
// "Alice: 6"
// "Bob: 1"
// "Alice: 7"
// "Bob: 2"
// ...
Hot observables function like people watching a live football match who all see the same thing at the same time; each observer gets events at the same time:
// Hot Observable Example
const sharedSeconds$ = interval(1000).pipe(share());
// Alice
sharedSeconds$.subscribe((n) => console.log(`Alice: ${n + 1}`));
// Bob subscribes after 5 seconds
setTimeout(() =>
sharedSeconds$.subscribe((n) => console.log(`Bob: ${n + 1}`))
, 5000);
/* Bob sees the same event as Alice now */
// ...
// "Alice: 6"
// "Bob: 6"
// "Alice: 7"
// "Bob: 7"
// ...
There’s a lot more you can do with RxJS, and it’s fair to say that a newcomer could be excused for being somewhat bewildered by the complexities of features like observers, operators, subjects, and schedulers, as well as multicast, unicast, finite, and infinite observables.
Thankfully, only stateful observables—a small subset of RxJS—are actually needed for state management, as I will explain next.
RxJS Stateful Observables
What do I mean by stateful observables?
First, these observables have the notion of a current value. Specifically, subscribers will get values synchronously, even before the next line of code is run:
// Assume name$ has current value "Fred"
console.log("Before subscription");
name$.subscribe(console.log);
console.log("After subscription");
// Logs:
// "Before subscription"
// "Fred"
// "After subscription"
Second, stateful observables emit an event every time the value changes. Furthermore, they’re hot, meaning all subscribers see the same events at the same time.
Holding State With the BehaviorSubject
Observable
RxJS’s BehaviorSubject
is a stateful observable with the above properties. The BehaviorSubject
observable wraps a value and emits an event every time the value changes (with the new value as the payload):
const numPieces$ = new BehaviorSubject(8);
numPieces$.subscribe((n) => console.log(`${n} pieces of cake left`));
// "8 pieces of cake left"
// Later…
numPieces$.next(2); // next(...) sets/emits the new value
// "2 pieces of cake left"
This seems to be just what we need to actually hold state, and this code will work with any data type. To tailor the code to single-page apps, we can leverage RxJS operators to make it more efficient.
Greater Efficiency With the distinctUntilChanged
Operator
When dealing with state, we prefer observables to only emit distinct values, so if the same value is set multiple times and duplicated, only the first value is emitted. This is important for performance in single-page apps, and can be achieved with the distinctUntilChanged
operator:
const rugbyScore$ = new BehaviorSubject(22),
distinctScore$ = rugbyScore$.pipe(distinctUntilChanged());
distinctScore$.subscribe((score) => console.log(`The score is ${score}`));
rugbyScore$.next(22); // distinctScore$ does not emit
rugbyScore$.next(27); // distinctScore$ emits 27
rugbyScore$.next(27); // distinctScore$ does not emit
rugbyScore$.next(30); // distinctScore$ emits 30
// Logs:
// "The score is 22"
// "The score is 27"
// "The score is 30"
The combination of BehaviorSubject
and distinctUntilChanged
achieves the most functionality for holding state. The next thing we need to solve is how to deal with derived state.
Derived State With the combineLatest
Function
Derived state is an important part of state management in single-page apps. This type of state is derived from other pieces of state; for example, a full name might be derived from a first name and a last name.
In RxJS, this can be achieved with the combineLatest
function, together with the map
operator:
const firstName$ = new BehaviorSubject("Jackie"),
lastName$ = new BehaviorSubject("Kennedy"),
fullName$ = combineLatest([firstName$, lastName$]).pipe(
map(([first, last]) => `${first} ${last}`)
);
fullName$.subscribe(console.log);
// Logs "Jackie Kennedy"
lastName$.next("Onassis");
// Logs "Jackie Onassis"
However, calculating derived state (the part inside the map
function above) can be an expensive operation. Rather than making the calculation for every observer, it would be better if we could perform it once, and cache the result to share between observers.
This is easily done by piping through the shareReplay
operator. We’ll also use distinctUntilChanged
again, so that observers aren’t notified if the calculated state hasn’t changed:
const num1$ = new BehaviorSubject(234),
num2$ = new BehaviorSubject(52),
result$ = combineLatest([num1$, num2$]).pipe(
map(([num1, num2]) => someExpensiveComputation(num1, num2)),
shareReplay(),
distinctUntilChanged()
);
result$.subscribe((result) => console.log("Alice sees", result));
// Calculates result
// Logs "Alice sees 9238"
result$.subscribe((result) => console.log("Bob sees", result));
// Uses CACHED result
// Logs "Bob sees 9238"
num2$.next(53);
// Calculates only ONCE
// Logs "Alice sees 11823"
// Logs "Bob sees 11823"
We have seen that BehaviorSubject
piped through the distinctUntilChanged
operator works well for holding state, and combineLatest
, piped through map
, shareReplay
, and distinctUntilChanged
, works well for managing derived state.
However, it is cumbersome to write these same combinations of observables and operators as a project’s scope expands, so I wrote a small library that provides a neat convenience wrapper around these concepts.
The rx-state
Convenience Library
Rather than repeat the same RxJS code each time, I wrote a small, free convenience library, rx-state
, that provides a wrapper around the RxJS objects mentioned above.
While RxJS observables are limited because they must share an interface with non-stateful observables, rx-state
offers convenience methods such as getters, which become useful now that we’re only interested in stateful observables.
The library revolves around two objects, the atom
, for holding state, and the combine
function, for dealing with derived state:
Concept | RxJs | rx-state |
---|---|---|
Holding State |
|
|
Derived State |
|
|
An atom can be thought of as a wrapper around any piece of state (a string, number, boolean, array, object, etc.) that makes it observable. Its main methods are get
, set
, and subscribe
, and it works seamlessly with RxJS.
const day$ = atom("Tuesday");
day$.subscribe(day => console.log(`Wake up, it's ${day}!`));
// Logs "Wake up, it's Tuesday!"
day$.get() // —> "Tuesday"
day$.set("Wednesday")
// Logs "Wake up, it's Wednesday!"
day$.get() // —> "Wednesday"
The full API can be found in the GitHub repository.
Derived state created with the combine
function looks just like an atom from the outside (in fact, it is a read-only atom):
const id$ = atom(77),
allUsers$ = atom({
42: {name: "Rosalind Franklin"},
77: {name: "Marie Curie"}
});
const user$ = combine([allUsers$, id$], ([users, id]) => users[id]);
// When user$ changes, then do something (i.e., console.log).
user$.subscribe(user => console.log(`User is ${user.name}`));
// Logs "User is Marie Curie"
user$.get() // —> "Marie Curie"
id$.set(42)
// Logs "User is Rosalind Franklin"
user$.get() // —> "Rosalind Franklin"
Note that the atom returned from combine
has no set
method, as it is derived from other atoms (or RxJS observables). As with atom, the full API for combine
can be found in the GitHub repository.
Now that we have an easy, efficient way to deal with state, our next step is to create reusable logic that can be used across different apps and frameworks.
The great thing is that we don’t need any more libraries for this, as we can easily encapsulate reusable logic using good old-fashioned JavaScript classes, creating stores.
Reusable JavaScript Stores
There’s no need to introduce more library code to deal with encapsulating state logic in reusable chunks, as a vanilla JavaScript class will suffice. (If you prefer more functional ways of encapsulating logic, these should be equally easy to realize, given the same building blocks: atom
and combine
.)
State can be publicly exposed as instance properties, and updates to the state can be done via public methods. As an example, imagine we want to keep track of the position of a player in a 2D game, with an x-coordinate and a y-coordinate. Furthermore, we want to know how far away the player has moved from the origin (0, 0):
import { atom, combine } from "@hungry-egg/rx-state";
// Our Player store
class Player {
// (0,0) is "bottom-left". Standard Cartesian coordinate system
x$ = atom(0);
y$ = atom(0);
// x$ and y$ are being observed; when those change, then update the distance
// Note: we are using the Pythagorean theorem for this calculation
distance$ = combine([this.x$, this.y$], ([x, y]) => Math.sqrt(x * x + y * y));
moveRight() {
this.x$.update(x => x + 1);
}
moveLeft() {
this.x$.update(x => x - 1);
}
moveUp() {
this.y$.update(y => y + 1);
}
moveDown() {
this.y$.update(y => y - 1);
}
}
// Instantiate a store
const player = new Player();
player.distance$.subscribe(d => console.log(`Player is ${d}m away`));
// Logs "Player is 0m away"
player.moveDown();
// Logs "Player is 1m away"
player.moveLeft();
// Logs "Player is 1.4142135623730951m away"
As this is just a plain JavaScript class, we can just use the private
and public
keywords in the way we usually would to expose the interface we want. (TypeScript provides these keywords and modern JavaScript has private class features.)
As a side note, there are cases in which you may want the exposed atoms to be read-only:
// allow
player.x$.get();
// subscribe but disallow
player.x$.set(10);
For these cases, rx-state
provides a couple of options.
Although what we’ve shown is fairly simple, we’ve now covered the basics of state management. Comparing our functional library to a common implementation like Redux:
- Where Redux has a store, we’ve used atoms.
- Where Redux handles derived state with libraries like Reselect, we’ve used
combine
. - Where Redux has actions and action creators, we simply have JavaScript class methods.
More to the point, as our stores are simple JavaScript classes that don’t require any other mechanism to work, they can be packaged up and reused across different applications—even across different frameworks. Let’s explore how they can be used in React.
React Integration
A stateful observable can easily be unwrapped into a raw value using React’s useState
and useEffect
hooks:
// Convenience method to get the current value of any "stateful observable"
// BehaviorSubjects already have the getValue method, but that won't work
// on derived state
function get(observable$) {
let value;
observable$.subscribe((val) => (value = val)).unsubscribe();
return value;
}
// Custom React hook for unwrapping observables
function useUnwrap(observable$) {
const [value, setValue] = useState(() => get(observable$));
useEffect(() => {
const subscription = observable$.subscribe(setValue);
return function cleanup() {
subscription.unsubscribe();
};
}, [observable$]);
return value;
}
Then, using the player example above, observables can be unwrapped into raw values:
// `player` would in reality come from elsewhere (e.g., another file, or provided with context)
const player = new Player();
function MyComponent() {
// Unwrap the observables into plain values
const x = useUnwrap(player.x$),
y = useUnwrap(player.y$);
const handleClickRight = () => {
// Update state by calling a method
player.moveRight();
};
return (
<div>
The player's position is ({x},{y})
<button onClick={handleClickRight}>Move right</button>
</div>
);
}
As with the rx-state
library, I’ve packaged the useWrap
hook, as well as some extra functionality, TypeScript support, and a few additional utility hooks into a small rx-react
library on GitHub.
A Note on Svelte Integration
Svelte users may well have noticed the similarity between atoms and Svelte stores. In this article, I refer to a “store” as a higher-level concept that ties together the atom building blocks, whereas a Svelte store refers to the building blocks themselves, and is on the same level as an atom. However, atoms and Svelte stores are still very similar.
If you are only using Svelte, you can use Svelte stores instead of atoms (unless you wanted to make use of piping through RxJS operators with the pipe
method). In fact, Svelte has a useful built-in feature: Any object that implements a particular contract can be prefixed with $
to be automatically unwrapped into a raw value.
RxJS observables also fulfill this contract after support updates. Our atom objects do too, so our reactive state can be used with Svelte as if it were a Svelte store with no modification.
Smooth React State Management With RxJS
RxJS has everything needed to manage state in JavaScript single-page apps:
- The
BehaviorSubject
withdistinctUntilChanged
operator provides a good basis for holding state. - The
combineLatest
function, with themap
,shareReplay
, anddistinctUntilChanged
operators, provides a basis for managing derived state.
However, using these operators by hand can be fairly cumbersome—enter rx-state
’s helper atom
object and combine
function. By encapsulating these building blocks in plain JavaScript classes, using the public/private functionality already provided by the language, we can build reusable state logic.
Finally, we can easily integrate smooth state management into React using hooks and the rx-react
helper library. Integrating with other libraries will often be even simpler, as shown with the Svelte example.
The Future of Observables
I predict a few updates to be most useful for the future of observables:
- Special treatment around the synchronous subset of RxJS observables (i.e., those with the notion of current value, two examples being
BehaviorSubject
and the observable resulting fromcombineLatest
); for example, maybe they’d all implement thegetValue()
method, as well as the usualsubscribe
, and so on.BehaviorSubject
already does this, but other synchronous observables don’t. - Support for native JavaScript observables, an existing proposal awaiting progress.
These changes would make the distinction between the different types of observables clearer, simplify state management, and bring greater power to the JavaScript language.
The editorial team of the Toptal Engineering Blog extends its gratitude to Baldeep Singh and Martin Indzhov for reviewing the code samples and other technical content presented in this article.
Further Reading on the Toptal Blog:
Understanding the basics
Is RxJS used in React?
Yes, RxJS can be used with many JavaScript frameworks, including React. RxJS is often used for managing side effects, but is also suited to managing state.
Is RxJS popular in React applications?
Yes, but it is less commonly encountered in React applications than, say, Redux. However, RxJS provides an elegant and lightweight alternative for state management within applications built around common front-end libraries.
What is state management in React?
State management in React refers to the management of data while the user interface renders. State management is concerned with where the data is held and how related data changes are communicated from the model into the user interface.
Is it good to use RxJS in React?
Yes, RxJS is powerful and works well with React. RxJS provides lightweight state management for applications built with common front-end libraries.
Why is state management important in React?
State management is a necessary part of front-end applications, providing a separation between application logic and user interface. Proper state management makes an application easy to understand, with reusable user interface components.
What is an RxJS Observable?
An RxJS Observable is a JavaScript object that can emit values and their updates to subscribers at any time. These data transmissions are observable, and side effects coincident with those events in other parts of the code can be arranged.
London, United Kingdom
Member since September 6, 2017
About the author
Mark is a full-stack software engineer who has written applications for major service companies including Intel, Disney, Sky, and Vodafone. He has led React and RxJS teams from prototype through commercial release, and holds master’s degrees in physics from Oxford University and King’s College.
Expertise
PREVIOUSLY AT