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);
-
subscribe(onStoreChange)— subscribe to the store, callonStoreChangewhenever it changes, return an unsubscribe function. -
getSnapshot()— return the current value. It must return the exact same reference if nothing changed; React compares snapshots withObject.isto 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);
}
Usage:
// store.ts
export const connectionStatus$ = new BehaviorSubject<"online" | "offline">("online");
// ConnectionBadge.tsx
function ConnectionBadge() {
const status = useBehaviorSubject(connectionStatus$);
return <span>{status === "online" ? "🟢" : "🔴"}</span>;
}
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()
);
...
}
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);
...
}
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() });
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);
}
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>
);
}
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 inuseCallback) — otherwiseuseMemorebuilds the observable and resubscribes on every render, the same problem as gotcha #1. -
distinctUntilChangedcompares with===by default, so it works out of the box for primitives (likecount) 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
);
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 ontouseSyncExternalStore'ssubscribe/getSnapshot. - Keep
subscribeandgetSnapshotreferentially stable (module-level functions, oruseCallback) — an unstablesubscriberesubscribes 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+distinctUntilChangedand feed that derived observable intouseSyncExternalStoreinstead of the raw subject, so unrelated field changes don't re-render every consumer. - Pass
getServerSnapshotif 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)