During our Angular-to-React (Next.js) migration, we evaluated every RxJS pattern against a single criterion: does this Observable emit more than once, or does it resolve a single value and complete? The answer determined the replacement strategy.
This article documents the patterns we found, the alternatives we applied, and the cases where RxJS remains the more precise tool, even inside a React codebase.
Versions used in this article:
- Angular v18 | RxJS v7.8
- React v19 | TanStack Query v5
Replacing RxJS Operator Chains with TanStack Query and Plain JavaScript
Which RxJS Operators Appear Most Often in Angular Codebases?
Three RxJS operators appeared consistently across our codebase:
- forkJoin - used for parallel API requests, emitting a single combined response once all requests are completed
- map - used for transforming API responses before they reach the component
- filter - used for filtering response data based on business rules
forkJoin - Parallel API Requests
// forkJoin example
const data1$ = fetchData1();
const data2$ = fetchData2();
forkJoin([data1$, data2$]).subscribe(([data1, data2]) => {
// Process combined data from both sources
console.log('Data 1:', data1);
console.log('Data 2:', data2);
});
map - Response Transformation
// map example
const data$ = fetchUser().pipe(
map(user => user.username)
);
filter - Result Filtering
// filter example
const numbers$ = of(1, 2, 3, 4, 5);
const evenNumbers$ = numbers$.pipe(
filter(num => num % 2 === 0)
); For each pattern, we asked the same question: does this Observable ever emit more than once?
The answer was almost always no. Every forkJoin emitted once and completed. Every map and filter transformed a single resolved value. There were no ongoing streams, no continuous emissions, no meaningful reactive behaviour. They were simply asynchronous data retrieval followed by plain data transformation.
RxJS to React Migration Mapping: What Replaces What?
forkJoin React Equivalent: useQueries with TanStack Query
// forkJoin equivalent
const combinedQueries = useQueries({
queries: [
{
queryKey: ['query1'],
queryFn: () => fetchData1()
},
{
queryKey: ['query2'],
queryFn: () => fetchData2()
}
],
combine: (results) => {
// Combine results from multiple queries
return {
data: results.map(result => result.data),
isLoading: results.some(result => result.isLoading)
}
}
})
map and filter React Equivalent: the select Option on useQuery
// map and filter equivalent
const { data, isLoading } = useQuery({
queryKey: ['user'],
queryFn: () => fetchUser(),
select: (user) => user.username
})
Long Polling in React: How TanStack Query Simplifies a Common Angular Pattern
Long polling can be implemented in several ways. One common Angular approach uses the interval RxJS operator to trigger periodic HTTP calls. In our case, we had used the native setInterval function with no RxJS involved.
Two Angular Approaches to Long Polling
1. Long Polling with the RxJS interval Operator
// Long polling with RxJS
const longPolling$ = interval(3 * 60 * 1000).pipe(
switchMap(() => fetchLongPollingData())
);
2. Long Polling with Native setInterval
// Long polling with setInterval
setInterval(() => {
fetchLongPollingData().then(data => {
console.log('Long polling data:', data);
});
}, 3 * 60 * 1000);
The React Equivalent: refetchInterval in TanStack Query
TanStack Query handles polling cleanly when the interval is fixed and there is no dynamic adjustment based on response data or application state.
The interval operator and setInterval both require manual cleanup on unmount, with errors and loading state handled by hand. TanStack Query provides all of these features out of the box.
// Long polling
useQuery({
queryKey: ['longPollingData'],
queryFn: () => fetchLongPollingData(),
refetchInterval: 3 * 60 * 1000 // Refetch every 3 minutes
})
Replacing BehaviorSubject as an Event Bus with TanStack Query Cache Invalidation
What Is the BehaviorSubject Event Bus Pattern Actually Doing?
BehaviorSubject has been used as a synchronisation mechanism between components. Component A mutates data and fires an event. Component B listens, receives it, and re-fetches.
Even though this looks like inter-component communication, it is not. The BehaviorSubject was never coordinating UI events, it was keeping server data in sync across components. It was solving a data freshness problem: ensuring that when one part of the application changes server state, every other part that depends on that data reflects the update.
BehaviorSubject as an Event Bus: Angular Implementation
// BehaviorSubject as an Event Bus
@Injectable({ providedIn: 'root' })
export class DataSyncService {
private dataChanged$ = new BehaviorSubject<boolean>(false);
notifyDataChanged(): void {
this.dataChanged$.next(true);
}
getDataChangedStatus(): Observable<boolean> {
return this.dataChanged$.asObservable();
}
}
// Module A — after mutation
this.apiService.updateRecord(payload).subscribe(() => {
this.dataSyncService.notifyDataChanged();
});
// Module B — reacts and re-fetches
this.dataSyncService.getDataChangedStatus().subscribe((dataChanged) => {
if (dataChanged) {
this.loadData();
}
});
The React Equivalent: Why invalidateQueries Replaces the Event Bus Entirely
TanStack Query's cache invalidation model makes the event bus pattern entirely unnecessary.
When a mutation completes, invalidateQueries marks all queries with the matching key as stale. What happens next depends on the current state of the query:
- Active query - refetches immediately in the background, updating all consumers automatically
- Inactive query - marked as stale and refetched the next time it becomes active, or when a trigger fires such as component mount or window focus, based on query configuration
// BehaviorSubject as an Event Bus → TanStack Query
() => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newData) => updateData(newData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['data'] });
}
})
}
Streams vs Snapshots: The Mental Model Shift from RxJS to React
RxJS operates on a push-based, stream-oriented reactivity model and it focuses on the event. An Observable, as the producer of multiple values, pushes them to Observers at its own pace. Observers react to the data received.
In contrast, reactivity in React is pull-based and state-oriented and it focuses on the result. When state changes, React pulls a new snapshot of the UI by re-rendering the component tree.
Neither model is wrong. They are optimised for different problems.
Where RxJS Still Belongs in a React Application
The replacements covered in this article address scenarios where RxJS was applied to problems that did not require reactivity. There remain cases where the problem is genuinely stream-oriented, where time, event sequencing, and continuous emissions are intrinsic to the requirement. For those scenarios, RxJS remains the more appropriate tool regardless of the surrounding framework.
Genuine Stream-Oriented Use Cases Where RxJS Outperforms React Alternatives
- Server-Sent Events (SSE)
- WebSockets
- Upload and download progress tracking
- Complex event choreography. For example: typeahead search, drag-and-drop sequences, and multi-step async workflows with cancellation
- Dynamic long polling
Conclusion: Know Whether Your Problem Is Reactive Before You Reach for RxJS
The core lesson from this migration was not that RxJS is the wrong tool. It is that we had been applying it to problems that were never reactive to begin with.
Once we started asking "what is this pattern actually doing?" rather than "how do we rewrite it?", most of the answers became straightforward. The React ecosystem, particularly TanStack Query handles the majority of what Angular developers reach for RxJS to solve.
RxJS still earns its place where genuine streams exist: Server-Sent Events, WebSockets, and the like. The key is to know the difference before you write the first line.



