Managing state in a reusable way using RxJS, React, and custom libraries

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

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.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 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!"
// ...

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 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 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 of function.
1
2
3
4
5
6
7
8
9
// 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

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 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"
// ...

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 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"
// ...

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Assume name$ has current value "Fred"

console.log("Before subscription");
name$.subscribe(console.log);
console.log("After subscription");

// Logs:
// "Before subscription"
// "Fred"
// "After subscription"

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:

1
2
3
4
5
6
7
8
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 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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, 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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’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

BehaviorSubject and distinctUntilChanged

atom

Derived State

combineLatest, map, shareReplay, and distinctUntilChanged

combine

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.

1
2
3
4
5
6
7
8
9
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"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 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:

1
2
3
4
5
// allow
player.x$.get();

// subscribe but disallow
player.x$.set(10);

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 combine to 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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;
}

Continuing with our player example, we can then unwrap our observables into their raw values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// `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>
  );
}

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 BehaviorSubject combined with the distinctUntilChanged operator forms a solid foundation for holding state.
  • The combineLatest function, along with the map, shareReplay, and distinctUntilChanged operators, 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 BehaviorSubject and observables resulting from combineLatest). For example, perhaps they could all implement a getValue() method alongside the standard subscribe method. BehaviorSubject already 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.

Licensed under CC BY-NC-SA 4.0