Pular para o conteúdo
< samuelsantana.dev />
Voltar para o Blog

RxJS Já Sabe Fazer loading/error/data: os Operators Por Trás do rx-state-bridge

Samuel Santana
Publicado em 07 de agosto de 2026
TypeScriptAngularReactSoftware Architecture

Toda tela que busca dado tem o mesmo esqueleto: um loading que vira true antes do request e false depois, um error que captura o que deu errado, um data que recebe o resultado. Três variáveis, sempre as mesmas, e ainda assim quase todo componente reescreve esse trio à mão — um useEffect com try/catch aqui, um subscribe({ next, error }) ali, cada implementação um pouco diferente da anterior por nenhum motivo além de ter sido escrita em dias diferentes.

rx-state-bridge nasceu de cansar de escrever essa mesma lógica pela enésima vez. A ideia é simples de enunciar — operators de RxJS que escrevem esse estado pra você — mas simples de enunciar não é o mesmo que simples de acertar, e a maior parte do trabalho da lib está em decisões pequenas que só aparecem quando você tenta cobrir React, Angular e Vue com o mesmo código.

Por que operators, e não um hook

A primeira versão que cheguei a prototipar era um hook: useRequestState(source$), devolvendo { loading, error, data } prontos. Funcionava bem pro caso feliz e morria no primeiro caso real, porque request nunca é só um request — é um request com debounce, com retry, com switchMap trocando a fonte no meio do caminho, às vezes com três chamadas em paralelo que precisam de um loading combinado. Um hook que devolve estado pronto não compõe com nada disso; ele é o fim da cadeia.

Um operator, em compensação, é só mais um passo dentro do pipe() que você já ia escrever. Ele não compete com debounceTime, retry ou switchMap — ele entra na mesma fila:

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

Isso resolve o problema de composição, mas empurra outro pra dentro: se o operator só escreve estado, ele precisa saber escrever em qualquer tipo de estado — um setLoading do React não tem a mesma forma que um signal() do Angular. Essa é a parte que ocupou mais tempo de design do que o resto da lib somado.

Um indicador, duas formas

React expõe estado como uma função — setLoading(true). Angular Signals expõe como um objeto com .set()loading.set(true). Vue usa um ref com .value. Pra um operator não precisar de uma versão por framework, rx-state-bridge aceita as duas formas mais comuns por um único tipo:

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

E por dentro, uma função normaliza isso numa chamada só, então cada operator é escrito uma vez e funciona nos dois 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]);

Mesma pipeline, dois frameworks, zero código condicional em quem chama. O .set() é checado antes do typeof, porque um WritableSignal do Angular também é chamável — é objeto com .set() e função ao mesmo tempo — então checar a forma mais específica primeiro é o que garante que os dois formatos caem no caminho certo sem ambiguidade.

Os operators, um por um

withLoading(indicator) é o mais direto: liga o indicador no subscribe, desliga no complete, no error ou no unsubscribe. Cobre o caso comum — spinner enquanto o request está no ar.

withSmoothLoading(indicator, minDuration) existe porque "loading" que pisca por 40ms é pior que não ter loading nenhum. Ele garante um piso: o indicador fica true por pelo menos minDuration ms mesmo que a resposta volte na hora, segurando a própria conclusão do stream até esse tempo passar. É o operator sendo honesto sobre quando ele realmente termina — o .subscribe() de quem está ouvindo só é avisado depois que a UI teve tempo de mostrar o loading de forma legível.

catchToState(errorIndicator, options?) captura o erro do stream e escreve no indicador, completando o stream em seguida em vez de deixar o erro estourar pra fora — pode devolver { rethrow: true } quando quem chama quer tratar o erro também. A decisão de design que mais gosto aqui: o erro se soma ao estado, não o substitui. Se já existia data de uma busca anterior bem-sucedida, ela continua na tela junto com o erro novo, em vez de a UI inteira colapsar pra uma tela de erro genérica. Manter a última tela boa visível enquanto mostra o que deu errado é, quase sempre, a experiência certa.

bindTo(state) escreve cada valor emitido no indicador de dado, sem interferir na emissão pra downstream — é o operator mais simples da lib e o mais usado.

bindRequestState(indicator, options?) combina os três de cima numa chamada só, pra quando o estado é um objeto único { loading, error, data } em vez de três 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?) cobre um caso diferente dos outros: aquele indicador de "salvo!" que aparece por dois segundos e some sozinho. Ao contrário de withSmoothLoading, ele completa o stream imediatamente — o reset depois de duration é decoração pós-fato, tipo um toast, e não deveria travar quem está ouvindo o complete real. Pra quando esse reset precisa ser cancelado (um novo save chegou antes do anterior terminar de "comemorar"), ele aceita um AbortSignal opcional:

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

// um novo id chegou: cancela o reset do save anterior
controller.abort();

Duas formas de "timer decorativo" — withSmoothLoading atrasando a conclusão de verdade, withTemporarySuccess completando na hora e cancelando por fora — porque o que cada timer representa é diferente, mesmo que o código pareça parecido à primeira vista.

combineLoading(...values) é o único que não é operator — é uma função pura que faz OR entre vários booleans de loading, pra telas com múltiplas fontes independentes:

const isAnythingLoading = combineLoading(usersLoading, ordersLoading);

O que fica

Nenhum desses operators faz nada que você não conseguiria escrever à mão em quinze linhas de subscribe({ next, error, complete }). O ganho não é poder — é não ter que decidir de novo, request após request, se o loading deve segurar por pelo menos 500ms, se o erro deve apagar o dado anterior ou conviver com ele, se o reset de "salvo" deve ser cancelável. Essas decisões, tomadas uma vez e encapsuladas num operator, param de ser um julgamento novo em cada componente e viram só mais um passo no pipe().

rx-state-bridge está no npm e o código é aberto no GitHub — zero dependências além do próprio RxJS como peer, testado contra React, Angular Signals e Vue Refs de verdade, não contra mocks de { set: vi.fn() }.

Comentários

Carregando comentários...

Participe da conversa

Entre com sua conta para comentar neste artigo.