
RxJS Already Knows How to Do loading/error/data: the Operators Behind rx-state-bridge
Every screen that fetches data has the same skeleton: a loading that flips to true before the request and false after, an error that captures whatever went wrong, a data that receives the result. Three variables, always the same ones, and yet almost every component rewrites that trio by hand β a useEffect with a try/catch here, a subscribe({ next, error }) there, each implementation slightly different from the last for no reason other than having been written on a different day.
rx-state-bridge came out of being tired of writing that same logic for the umpteenth time. The idea is simple to state β RxJS operators that write that state for you β but simple to state isn't the same as simple to get right, and most of the library's work sits in small decisions that only surface once you try to cover React, Angular, and Vue with the same code.
Why operators, and not a hook
The first version I prototyped was a hook: useRequestState(source$), returning a ready-made { loading, error, data }. It worked fine for the happy path and died on the first real case, because a request is never just a request β it's a request with debounce, with retry, with a switchMap swapping the source mid-flight, sometimes three calls in parallel that need a combined loading flag. A hook that hands back finished state doesn't compose with any of that; it is the end of the chain.
An operator, on the other hand, is just one more step inside the pipe() you were already going to write. It doesn't compete with debounceTime, retry, or switchMap β it joins the same line:
source$.pipe(
debounceTime(300),
switchMap((query) => search$(query)),
withSmoothLoading(setLoading, 500),
catchToState(setError),
bindTo(setData),
);
That solves the composition problem, but pushes another one inward: if the operator only writes state, it needs to know how to write into any shape of state β a setLoading from React doesn't have the same shape as a signal() from Angular. That's the part that took more design time than the rest of the library combined.
One indicator, two shapes
React exposes state as a function β setLoading(true). Angular Signals expose it as an object with .set() β loading.set(true). Vue uses a ref with .value. So that an operator doesn't need one version per framework, rx-state-bridge accepts both common shapes through a single type:
type StateIndicator<T> = ((value: T) => void) | { set: (value: T) => void };
And under the hood, a normalizer collapses that into a single call, so every operator is written once and works in both worlds:
readonly loading = signal(false);
readonly error = signal<unknown>(null);
readonly data = signal<User | null>(null);
readonly user$ = this.fetchUser(this.id).pipe(
withSmoothLoading(this.loading, 500),
catchToState(this.error),
bindTo(this.data),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => {
const sub = fetchUser$(id)
.pipe(
withSmoothLoading(setLoading, 500),
catchToState(setError),
bindTo(setData),
)
.subscribe();
return () => sub.unsubscribe();
}, [id]);
Same pipeline, two frameworks, zero conditional code on the caller's side. The .set() check runs before the typeof check, because an Angular WritableSignal is also callable β it's an object with .set() and a function at the same time β so checking the more specific shape first is what guarantees both formats land on the right path without ambiguity.
The operators, one by one
withLoading(indicator) is the most direct one: flips the indicator on subscribe, flips it back off on complete, error, or unsubscribe. Covers the common case β a spinner while the request is in flight.
withSmoothLoading(indicator, minDuration) exists because a "loading" that flickers for 40ms is worse than no loading at all. It guarantees a floor: the indicator stays true for at least minDuration ms even if the response comes back instantly, by holding the stream's own completion until that time has passed. It's the operator being honest about when it actually finishes β whoever's .subscribe()ing only gets told after the UI has had time to show the loading state legibly.
catchToState(errorIndicator, options?) captures the stream's error and writes it into the indicator, completing the stream afterward instead of letting the error blow past it β it accepts { rethrow: true } for when the caller wants to handle the error too. The design decision I like most here: the error adds to the state, it doesn't replace it. If data from an earlier successful fetch already existed, it stays on screen alongside the new error, instead of the whole UI collapsing into a generic error screen. Keeping the last good screen visible while showing what went wrong is, almost always, the right experience.
bindTo(state) writes every emitted value into the data indicator, without interfering with the downstream emission β the simplest operator in the library, and the most used.
bindRequestState(indicator, options?) combines the three above into a single call, for when state is one object { loading, error, data } instead of three separate indicators:
const [state, setState] = useState({ loading: false, error: null, data: null });
useEffect(() => {
const sub = fetchUser$(id).pipe(bindRequestState(setState)).subscribe();
return () => sub.unsubscribe();
}, [id]);
withTemporarySuccess(indicator, duration, options?) covers a different case from the others: that "saved!" indicator that shows up for two seconds and disappears on its own. Unlike withSmoothLoading, it completes the stream immediately β the reset after duration is decoration layered on after the fact, like a toast, and shouldn't block whoever's listening for the real complete. For when that reset needs to be cancelled (a new save arrived before the previous one finished "celebrating"), it accepts an optional AbortSignal:
const controller = new AbortController();
save$(id)
.pipe(withTemporarySuccess(setSaved, 2000, { signal: controller.signal }))
.subscribe();
// a new id arrived: cancel the previous save's reset
controller.abort();
Two forms of "decorative timer" β withSmoothLoading delaying the real completion, withTemporarySuccess completing immediately and cancelling from the outside β because what each timer represents is different, even though the code looks similar at first glance.
combineLoading(...values) is the only one that isn't an operator β it's a pure function that ORs several loading booleans together, for screens with multiple independent sources:
const isAnythingLoading = combineLoading(usersLoading, ordersLoading);
What's left
None of these operators do anything you couldn't write by hand in fifteen lines of subscribe({ next, error, complete }). The gain isn't power β it's not having to decide again, request after request, whether loading should hold for at least 500ms, whether an error should wipe the previous data or coexist with it, whether the "saved" reset should be cancellable. Those decisions, made once and wrapped into an operator, stop being a fresh judgment call in every component and become just another step in the pipe().
rx-state-bridge is on npm, and the code is open on GitHub β zero dependencies beyond RxJS itself as a peer, tested against real React, Angular Signals, and Vue Refs, not { set: vi.fn() } mocks.
Comments
Loading comments...
Join the conversation
Sign in with your account to comment on this article.