npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

rx-state-bridge

v1.2.4

Published

Framework-agnostic RxJS operators that bridge async streams with UI state primitives (React Hooks, Angular Signals, Vue Refs) — declarative loading, error and success feedback with zero boilerplate.

Downloads

849

Readme

Framework-agnostic RxJS operators that bridge async streams with UI state primitives — React Hooks, Angular Signals, Vue Refs, or anything else exposing a callback or a .set() method.

CI Coverage CodeQL npm version npm downloads License: MIT TypeScript strict Zero dependencies

Live demo — every operator running in the browser against a fake API, no install needed.

Stop hand-rolling loading/error/success boilerplate around every API call.

  • Zero dependencies (RxJS is a peer dependency only)
  • Framework-agnostic — no React/Angular/Vue imports, ever
  • Memory-safe — every side effect starts on subscribe (defer) and is torn down on completion, error, or unsubscription (finalize)

Install

npm install rx-state-bridge rxjs

Quick example

import { withSmoothLoading, catchToState, bindTo } from 'rx-state-bridge';

const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);

useEffect(() => {
  const sub = fetchUser$(id)
    .pipe(
      withSmoothLoading(setLoading, 500), // no spinner flicker on fast responses
      catchToState(setError), // capture errors into state, complete gracefully
      bindTo(setData), // write the result into state
    )
    .subscribe();

  return () => sub.unsubscribe();
}, [id]);

The same pipeline works unchanged with Angular signals:

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),
);

Examples

Live, editable demos for every operator — one card per operator, running against a fake in-memory API (no real network). The React one is also deployed from main as the live demo:

Or run either locally:

cd examples/react   # or examples/angular
npm install
npm run dev          # examples/angular uses `npm run start`

API

| Operator | Purpose | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | withLoading(indicator) | Sets indicator to true on subscribe, false on completion/error/unsubscribe. | | withSmoothLoading(indicator, minDuration) | Like withLoading, but keeps the indicator true for at least minDuration ms to avoid spinner flicker. | | catchToState(errorIndicator, options?) | Captures errors into errorIndicator, then completes gracefully (default) or re-throws ({ rethrow: true }). | | withTemporarySuccess(indicator, duration, options?) | Sets indicator to true on successful completion, then resets it to false after duration ms. Completion itself is never delayed — pass { signal } to cancel a still-pending reset early. | | bindTo(state) | Writes every emitted value into state, forwarding it downstream unchanged. | | bindRequestState(indicator, options?) | Fuses withLoading + catchToState + bindTo into one write: sets indicator to { loading, error, data } across the stream's lifecycle. | | combineLoading(...values) | Plain helper (not an operator) that ORs several loading booleans into one. Call it inside your own render/computed(). |

All operators accept a StateIndicator<T>:

type StateIndicator<T> = ((value: T) => void) | { set: (value: T) => void };

Cancelling a pending withTemporarySuccess reset

withTemporarySuccess completes immediately — the duration timer is a background reset, not something that blocks whatever runs after .subscribe(). If you reuse the same indicator across overlapping or sequential requests (e.g. re-running an effect when id changes), a superseded request's reset could otherwise land after a newer one has already moved on. Pass an AbortSignal to cancel it explicitly:

const [saved, setSaved] = useState(false);

useEffect(() => {
  const controller = new AbortController();
  const sub = save$(id)
    .pipe(withTemporarySuccess(setSaved, 2000, { signal: controller.signal }))
    .subscribe();

  return () => {
    controller.abort();
    sub.unsubscribe();
  };
}, [id]);

withSmoothLoading, by contrast, needs no such option: unsubscribing at any point — including mid-way through its own minDuration grace period — always cancels the pending timer and resets the indicator immediately.

Collapsing loading/error/data into one state with bindRequestState

The Quick example above wires three separate state setters. bindRequestState collapses them into one:

// Before — three setters, three operators
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown>(null);

useEffect(() => {
  const sub = fetchUser$(id)
    .pipe(withLoading(setLoading), catchToState(setError), bindTo(setData))
    .subscribe();
  return () => sub.unsubscribe();
}, [id]);

// After — one setter, one operator
const [user, setUser] = useState<RequestState<User>>({ loading: false, error: null, data: null });

useEffect(() => {
  const sub = fetchUser$(id).pipe(bindRequestState(setUser)).subscribe();
  return () => sub.unsubscribe();
}, [id]);

data is only ever overwritten by a new emission — an error never clears a previously-received value, so the last good result stays visible alongside the error. Reach for the individual operators instead when you need withSmoothLoading's flicker-free timing or withTemporarySuccess's toast-style feedback; bindRequestState covers the plain loading/error/data case.

Combining multiple loading flags with combineLoading

For a dashboard driving several independent, unrelated fetches that should collapse into one spinner:

const [usersLoading, setUsersLoading] = useState(false);
const [ordersLoading, setOrdersLoading] = useState(false);

const isAnythingLoading = combineLoading(usersLoading, ordersLoading);

It's a plain function, not an RxJS operator — call it inside your own render/computed() so it re-evaluates whenever any underlying value changes.

Design notes

A couple of decisions in this library aren't obvious from the API surface alone. Written down here so they don't turn into repeated GitHub issues.

Why does withSmoothLoading delay complete/error, but withTemporarySuccess doesn't? Both operators have a timer between "the source finished" and "the indicator settles." The difference is what that timer is for. withSmoothLoading's grace period is a correction to the indicator's own timing — the whole point is that the operator shouldn't tell you it's done until the minimum duration has actually elapsed, so holding the stream's own completion for that (bounded, capped at minDuration) window is the operator being honest about when it actually finished. withTemporarySuccess's reset window is different in kind: it's decorative feedback (a toast, a checkmark) layered after the real work is already done. Delaying complete there — for the full duration, often 2s+ — would block whatever the caller does next (subscribe(() => navigate()), a chained operator) for a UI detail that has nothing to do with it. So withSmoothLoading fixes correctness by holding completion; withTemporarySuccess keeps completion immediate and instead accepts an optional signal: AbortSignal so a caller who actually needs to cancel the pending reset (e.g. a superseded request sharing one indicator) can opt in, without paying for it by default.

This is also why withSmoothLoading needed no new API to become fully safe (unsubscribing during the grace window has always been the fix to reach for), while withTemporarySuccess's fix is additive and opt-in — the two bugs looked identical on the surface (a detached timer(...).subscribe(...) with no link to the outer subscription) but the correct fix for each followed from what the timer represents, not just from "make it cancellable."

Why does an error never clear previously-received data? catchToState, bindRequestState, and the RequestState<T> shape all treat a caught error as additional information, not a replacement for what you already had. A failed refresh shouldn't blank out the last good screen — showing stale-but-real data next to a fresh error is almost always closer to what the UI should do than clearing to empty. If you want the old-fashioned "wipe on error" behavior, that's one line at the call site (data: null in your own error handler) rather than something the library should force on everyone.

Development

npm run dev     # watch build
npm run build   # ESM + CJS + .d.ts via tsup
npm run test    # vitest
npm run lint    # eslint + prettier --check

Contributing

See CONTRIBUTING.md for the branching model, commit conventions, and how to add a changeset.

License

MIT