DEV Community

Cover image for Bridging RxJS BehaviorSubject and React with useSyncExternalStore
Maksim
Maksim

Posted on

Bridging RxJS BehaviorSubject and React with useSyncExternalStore

If you keep shared, mutable state in an RxJS BehaviorSubject — a WebSocket connection status, a design-system theme, a cache shared between routed pages — you eventually need to read that state from a React component and re-render when it changes. For years the answer was a hand-rolled useEffect + useState subscription, react-rxjs, or useObservable from some utility library.

Since React 18 there's a built-in, purpose-built hook for exactly this: useSyncExternalStore. It's the same primitive Redux, Zustand, and Jotai use under the hood, and it plugs into a BehaviorSubject almost for free — because a BehaviorSubject already is an external store: it holds a current value (getValue()) and lets you subscribe to changes.

This post walks through wiring the two together, the two gotchas that will bite you if you skip them, and how to select a slice of a larger observable state without over-rendering.

The store contract

useSyncExternalStore takes two functions and returns the current value:

const value = useSyncExternalStore(subscribe, getSnapshot);
Enter fullscreen mode Exit fullscreen mode
  • subscribe(onStoreChange) — subscribe to the store, call onStoreChange whenever it changes, return an unsubscribe function.
  • getSnapshot() — return the current value. It must return the exact same reference if nothing changed; React compares snapshots with Object.is to decide whether to re-render.

A BehaviorSubject maps onto this directly: subject.subscribe(fn) is your subscribe, and subject.getValue() is your getSnapshot.

A minimal adapter

import { BehaviorSubject } from "rxjs";
import { useSyncExternalStore, useCallback } from "react";

export function useBehaviorSubject<T>(subject: BehaviorSubject<T>): T {
  const subscribe = useCallback(
    (onStoreChange: () => void) => {
      const subscription = subject.subscribe(onStoreChange);
      return () => subscription.unsubscribe();
    },
    [subject]
  );

  const getSnapshot = useCallback(() => subject.getValue(), [subject]);

  return useSyncExternalStore(subscribe, getSnapshot);
}
Enter fullscreen mode Exit fullscreen mode

Usage:

// store.ts
export const connectionStatus$ = new BehaviorSubject<"online" | "offline">("online");

// ConnectionBadge.tsx
function ConnectionBadge() {
  const status = useBehaviorSubject(connectionStatus$);
  return <span>{status === "online" ? "🟢" : "🔴"}</span>;
}
Enter fullscreen mode Exit fullscreen mode

That's the whole integration. Two details make the difference between "works" and "resubscribes on every keystroke and eventually falls over."

Gotcha #1: stabilize subscribe

useSyncExternalStore resubscribes whenever the subscribe function reference changes. If you define it inline in the component body without useCallback, it's a new function on every render, so React tears down and re-creates the RxJS subscription on every render too — wasteful at best, and a source of subtle timing bugs (a store change firing between unsubscribe and resubscribe can be missed) at worst:

// Don't do this — new closure every render, resubscribes constantly
function ConnectionBadge() {
  const status = useSyncExternalStore(
    (cb) => {
      const sub = connectionStatus$.subscribe(cb);
      return () => sub.unsubscribe();
    },
    () => connectionStatus$.getValue()
  );
  ...
}
Enter fullscreen mode Exit fullscreen mode

Wrapping it in useCallback (as in the adapter above), or — better — defining subscribe/getSnapshot once outside the component when the subject itself is module-level, fixes it:

// subject is module-level, so subscribe/getSnapshot can be too
const subscribe = (cb: () => void) => {
  const sub = connectionStatus$.subscribe(cb);
  return () => sub.unsubscribe();
};
const getSnapshot = () => connectionStatus$.getValue();

function ConnectionBadge() {
  const status = useSyncExternalStore(subscribe, getSnapshot);
  ...
}
Enter fullscreen mode Exit fullscreen mode

Gotcha #2: never synthesize a new object in getSnapshot

React uses Object.is on whatever getSnapshot returns. BehaviorSubject.getValue() returns the stored reference as-is, so as long as you don't transform it in getSnapshot, you're safe:

// Fine — same reference until the subject emits a new one
const getSnapshot = () => connectionStatus$.getValue();

// Broken — new object every call, infinite render loop
const getSnapshot = () => ({ status: connectionStatus$.getValue() });
Enter fullscreen mode Exit fullscreen mode

If you need a derived value (a slice of a bigger state object, a computed flag), derive it in RxJS before it reaches getSnapshot, with distinctUntilChanged so the underlying subject only "changes" (from the hook's point of view) when the derived value actually changes. That's the selector pattern below.

One quiet behavior worth knowing

BehaviorSubject calls its subscriber synchronously and immediately with the current value on subscribe(). That means the onStoreChange callback you pass to useSyncExternalStore fires once, synchronously, during the initial subscribe — before any real change happened. This is harmless: getSnapshot() at that point returns the same reference useSyncExternalStore already has, so Object.is short-circuits and no extra render happens. Worth knowing mainly so it doesn't look like a bug the first time you log inside subscribe.

Selecting a slice, with plain RxJS

A single BehaviorSubject often holds more than one component needs — a whole app-state object, say. If every consumer just called useBehaviorSubject(state$), every consumer would re-render on any change to that object, even to fields it doesn't read.

You don't need another package for this — RxJS already ships the tool: map + distinctUntilChanged. Turn the selection into its own observable, and feed that into useSyncExternalStore instead of the raw subject.

The one wrinkle is that getSnapshot has to be synchronous and callable at any time, while the derived observable only pushes a new value when something changes. So the adapter needs a small mutable cell that holds "the last selected value," seeded synchronously and updated by the subscription:

import { BehaviorSubject } from "rxjs";
import { map, distinctUntilChanged } from "rxjs/operators";
import { useSyncExternalStore, useMemo } from "react";

function useSelectFromSubject<T, R>(
  subject: BehaviorSubject<T>,
  selector: (state: T) => R
): R {
  const { subscribe, getSnapshot } = useMemo(() => {
    const selected$ = subject.pipe(map(selector), distinctUntilChanged());
    let current = selector(subject.getValue()); // synchronous initial value

    const subscribe = (onStoreChange: () => void) => {
      const sub = selected$.subscribe((value) => {
        current = value;
        onStoreChange();
      });
      return () => sub.unsubscribe();
    };
    const getSnapshot = () => current;

    return { subscribe, getSnapshot };
  }, [subject, selector]);

  return useSyncExternalStore(subscribe, getSnapshot);
}
Enter fullscreen mode Exit fullscreen mode

A component that only cares about count never re-renders when user changes:

interface AppState {
  user: { name: string };
  count: number;
}

const state$ = new BehaviorSubject<AppState>({ user: { name: "Max" }, count: 0 });

function Counter() {
  const count = useSelectFromSubject(state$, (s) => s.count);
  return (
    <button onClick={() => state$.next({ ...state$.getValue(), count: count + 1 })}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things to watch with this version:

  • The selector passed to useMemo's dependency array needs a stable reference (define it outside the component, or wrap it in useCallback) — otherwise useMemo rebuilds the observable and resubscribes on every render, the same problem as gotcha #1.
  • distinctUntilChanged compares with === by default, so it works out of the box for primitives (like count) but not for selectors that return a new object or array each time. Pass your own comparator — distinctUntilChanged((a, b) => shallowEqual(a, b)) — if the selected slice isn't a primitive.

I checked this against a small harness that mimics React's Object.is-comparison loop: updating user alone never triggered the count selector's onStoreChange, while updating count did — exactly the isolation you want from a selector, with nothing beyond rxjs and react in the dependency tree.

Server-side rendering

If you render on the server, pass a third argument — getServerSnapshot — or React throws during SSR. For a BehaviorSubject, that's usually just the value it was seeded with:

const status = useSyncExternalStore(
  subscribe,
  getSnapshot,
  () => "online" as const // value to use during SSR/hydration
);
Enter fullscreen mode Exit fullscreen mode

Why this beats the useEffect version

The naive alternative — useState seeded from getValue(), updated in a useEffect subscription — works, but has two problems useSyncExternalStore was built to fix: it can tear during concurrent rendering (a component can read a stale value mid-render because the subscription updates state after render, not during it), and it doesn't support startTransition/Suspense-aware scheduling correctly. useSyncExternalStore reads the store synchronously during render, so what you see on screen is always consistent with the actual subject value, even under concurrent features.

Summary

  • BehaviorSubject.subscribe / BehaviorSubject.getValue() map directly onto useSyncExternalStore's subscribe / getSnapshot.
  • Keep subscribe and getSnapshot referentially stable (module-level functions, or useCallback) — an unstable subscribe resubscribes on every render.
  • Never build a new object inside getSnapshot; return the subject's value as-is.
  • For a derived slice of a larger state object, pipe the subject through map + distinctUntilChanged and feed that derived observable into useSyncExternalStore instead of the raw subject, so unrelated field changes don't re-render every consumer.
  • Pass getServerSnapshot if you render on the server.

The whole point of useSyncExternalStore is that it's a narrow, boring primitive — which is exactly what makes it a good fit for something like RxJS that already models "current value + subscription" as its core abstraction.

Top comments (0)