Software Engineering

Using RxJS and React for Reusable State Management


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

Redux

  • Widely used; effective in providing structure to state management.
  • Built on the Elm architecture, demonstrating that it works for single-page applications.
  • Requires developers to work with immutable data.
  • Heavy and complex.
  • Requires considerable amounts of boilerplate code.
  • Difficult to reuse due to its reducers (e.g., actions, action-creators, selectors, thunks) all hooking into a central store.

Vuex

  • Uses a single central store.
  • Provides a modules mechanism that works well for state logic reuse.
  • Mainly for use with VueJS apps.

MobX

  • Provides reusable store classes.
  • Reduces boilerplate and complexity issues.
  • Hides its implementation magic through heavy proxy-object use.
  • Challenges reusing pure presentational components, as they must be wrapped in order to become MobX-aware.

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

BehaviorSubject and distinctUntilChanged

atom

Derived State

combineLatest, map, shareReplay, and distinctUntilChanged

combine

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 with distinctUntilChanged operator provides a good basis for holding state.
  • The combineLatest function, with the map, shareReplay, and distinctUntilChanged 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 from combineLatest); for example, maybe they’d all implement the getValue() method, as well as the usual subscribe, 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.