
RxJS Já Sabe Fazer loading/error/data: os Operators Por Trás do rx-state-bridge
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.