Saltar al contenido
< samuelsantana.dev />
Volver al Blog

RxJS Ya Sabe Hacer loading/error/data: los Operators Detrás de rx-state-bridge

Samuel Santana
Publicado el 07 de agosto de 2026
TypeScriptAngularReactSoftware Architecture

Toda pantalla que busca datos tiene el mismo esqueleto: un loading que pasa a true antes del request y a false después, un error que captura lo que salió mal, un data que recibe el resultado. Tres variables, siempre las mismas, y aun así casi todo componente reescribe ese trío a mano — un useEffect con try/catch aquí, un subscribe({ next, error }) allá, cada implementación un poco distinta de la anterior sin más motivo que haber sido escrita en un día diferente.

rx-state-bridge nació de cansarme de escribir esa misma lógica por enésima vez. La idea es simple de enunciar — operators de RxJS que escriben ese estado por ti — pero simple de enunciar no es lo mismo que simple de acertar, y la mayor parte del trabajo de la librería está en decisiones pequeñas que solo aparecen cuando intentas cubrir React, Angular y Vue con el mismo código.

Por qué operators, y no un hook

La primera versión que prototipé fue un hook: useRequestState(source$), que devolvía un { loading, error, data } ya listo. Funcionaba bien para el caso feliz y moría en el primer caso real, porque un request nunca es solo un request — es un request con debounce, con retry, con un switchMap cambiando la fuente a mitad de camino, a veces con tres llamadas en paralelo que necesitan un loading combinado. Un hook que devuelve estado ya resuelto no compone con nada de eso; él es el final de la cadena.

Un operator, en cambio, es solo un paso más dentro del pipe() que ya ibas a escribir. No compite con debounceTime, retry o switchMap — entra en la misma fila:

source$.pipe(
  debounceTime(300),
  switchMap((query) => search$(query)),
  withSmoothLoading(setLoading, 500),
  catchToState(setError),
  bindTo(setData),
);

Eso resuelve el problema de composición, pero empuja otro hacia adentro: si el operator solo escribe estado, necesita saber escribir en cualquier forma de estado — un setLoading de React no tiene la misma forma que un signal() de Angular. Esa es la parte que ocupó más tiempo de diseño que el resto de la librería junto.

Un indicador, dos formas

React expone el estado como una función — setLoading(true). Angular Signals lo expone como un objeto con .set()loading.set(true). Vue usa un ref con .value. Para que un operator no necesite una versión por framework, rx-state-bridge acepta ambas formas comunes mediante un solo tipo:

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

Y por dentro, un normalizador reduce eso a una sola llamada, así que cada operator se escribe una vez y funciona en los dos mundos:

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

Misma pipeline, dos frameworks, cero código condicional en quien llama. El check de .set() corre antes del typeof, porque un WritableSignal de Angular también es invocable — es objeto con .set() y función al mismo tiempo — así que comprobar la forma más específica primero es lo que garantiza que ambos formatos caigan en el camino correcto sin ambigüedad.

Los operators, uno por uno

withLoading(indicator) es el más directo: enciende el indicador en el subscribe, lo apaga en el complete, en el error o en el unsubscribe. Cubre el caso común — un spinner mientras el request está en el aire.

withSmoothLoading(indicator, minDuration) existe porque un "loading" que parpadea 40ms es peor que no tener loading. Garantiza un piso: el indicador se mantiene en true por al menos minDuration ms aunque la respuesta vuelva al instante, reteniendo la propia conclusión del stream hasta que ese tiempo haya pasado. Es el operator siendo honesto sobre cuándo termina de verdad — quien esté haciendo .subscribe() solo se entera después de que la UI tuvo tiempo de mostrar el loading de forma legible.

catchToState(errorIndicator, options?) captura el error del stream y lo escribe en el indicador, completando el stream a continuación en vez de dejar que el error se propague — acepta { rethrow: true } para cuando quien llama también quiere manejar el error. La decisión de diseño que más me gusta aquí: el error se suma al estado, no lo reemplaza. Si ya existía data de una búsqueda anterior exitosa, sigue en pantalla junto con el error nuevo, en vez de que toda la UI colapse a una pantalla de error genérica. Mantener la última pantalla buena visible mientras se muestra lo que salió mal es, casi siempre, la experiencia correcta.

bindTo(state) escribe cada valor emitido en el indicador de datos, sin interferir con la emisión hacia downstream — el operator más simple de la librería, y el más usado.

bindRequestState(indicator, options?) combina los tres anteriores en una sola llamada, para cuando el estado es un único objeto { loading, error, data } en vez de tres indicadores separados:

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?) cubre un caso distinto de los demás: ese indicador de "¡guardado!" que aparece por dos segundos y desaparece solo. A diferencia de withSmoothLoading, completa el stream de inmediato — el reset tras duration es decoración añadida después del hecho, como un toast, y no debería bloquear a quien está escuchando el complete real. Para cuando ese reset necesita cancelarse (llegó un nuevo save antes de que el anterior terminara de "celebrar"), acepta un AbortSignal opcional:

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

// llegó un id nuevo: cancela el reset del save anterior
controller.abort();

Dos formas de "timer decorativo" — withSmoothLoading retrasando la conclusión real, withTemporarySuccess completando de inmediato y cancelando desde afuera — porque lo que representa cada timer es distinto, aunque el código parezca parecido a primera vista.

combineLoading(...values) es el único que no es operator — es una función pura que hace OR entre varios booleans de loading, para pantallas con múltiples fuentes independientes:

const isAnythingLoading = combineLoading(usersLoading, ordersLoading);

Lo que queda

Ninguno de estos operators hace algo que no pudieras escribir a mano en quince líneas de subscribe({ next, error, complete }). La ganancia no es poder — es no tener que decidir de nuevo, request tras request, si el loading debe sostenerse por al menos 500ms, si un error debe borrar el dato anterior o convivir con él, si el reset de "guardado" debe ser cancelable. Esas decisiones, tomadas una vez y encapsuladas en un operator, dejan de ser un juicio nuevo en cada componente y pasan a ser solo un paso más en el pipe().

rx-state-bridge está en npm y el código está abierto en GitHub — cero dependencias además del propio RxJS como peer, probado contra React, Angular Signals y Vue Refs reales, no contra mocks de { set: vi.fn() }.

Comentarios

Cargando comentarios...

Únete a la conversación

Inicia sesión con tu cuenta para comentar este artículo.