DEV Community

Cover image for Modernizing Legacy Apps with RxJS: Streams as the Bridge Between Old and New
Emmanuel R for CobuildX AI

Posted on Originally published at cobuildx.ai

Modernizing Legacy Apps with RxJS: Streams as the Bridge Between Old and New

In most legacy migrations, the difficult part is the async code, not the templates: callbacks, observers, global events and timers that every screen depends on. This post explains how we use RxJS as a bridge, so the old UI and the new React UI share the same streams while screens are moved one by one.

In most legacy frontend migrations, the templates are not the hard part. Templates can be ported quite easily. The hard part is the async code. A jQuery handler sends a request, sets a global flag and fires a custom event. A Backbone model has three views listening to its change event. An Ember observer saves data on every keystroke. A setInterval keeps polling the server, and nobody in the team knows why it was added.

This is where the actual behavior of a legacy app lives, and it is spread across every page. We cannot move it one route at a time, because both the old screens and the new screens depend on it.

In such projects, we use RxJS as a bridge between the old app and the new one. We do not use it as the final architecture, and we do not use it just because Angular uses it. The approach is simple. Wrap each legacy event source in an Observable and keep the shared state behind a stream. Now the old UI and the new React UI can subscribe to the same data while screens are moved one by one. As a bonus, a lot of hand-written debounce, retry and cancel logic becomes a few lines of operators. This is the branch by abstraction pattern, with streams as the interface.

The Short Version

Legacy pattern What goes wrong RxJS replacement
Callbacks and $.Deferred chains Deeply nested, cannot be cancelled, errors get lost from(promise), defer, switchMap
Global or custom events ($(document).trigger, Backbone on) Any code can fire them, and nobody removes the listeners fromEvent, fromEventPattern, typed Subjects
Observers and watchers (Ember observer, $scope.$watch) Order is not clear, and they run too often BehaviorSubject + distinctUntilChanged
Hand-rolled debounce and timers Old responses overwrite new ones, timers keep running debounceTime, switchMap, takeUntil
Polling with setInterval Requests overlap, and polling never stops timer + switchMap / exhaustMap
Retry loops Too many retries, no delay between them retry({ count, delay })
WebSockets with manual reconnects Duplicate connections, lost messages webSocket + retry + share

Every row follows the same idea: treat "something that happens over time" as a value that we can pass around, combine and unsubscribe from. Once both apps use Observables, the framework on each side becomes much less important.

Why RxJS Works Well for Modernization

There are many state management libraries. For a migration, RxJS is useful for three specific reasons:

  • It does not depend on any framework. An Observable works the same way whether jQuery, Backbone, Ember, AngularJS or React subscribes to it. This is exactly what we need during a migration, when the old and new code run on the same page.
  • One model for every kind of async. DOM events, custom events, promises, timers, WebSockets and Node-style callbacks all become the same type. There is no need to write a different pattern for each source.
  • Cancellation and cleanup are built in. Every subscription can be closed. Legacy apps often leave handlers and timers running, and many of these leaks are bugs that users have simply got used to.

There is also one clear reason to be careful: RxJS takes time to learn, and a codebase where everything is a stream is difficult to read. We will discuss where to stop later in this post. In short, use it at the boundaries and for real event streams, not for every value in the app.

Step 1: Wrap the Legacy Event Sources

First, find every place where the old app produces events or async results, and create an Observable wrapper for each one. Do not change the legacy code at this stage. The wrapper only listens.

Legacy event sources on the left: jQuery events, Backbone models, Ember observers, AngularJS watchers, promises and callbacks, timers, WebSockets. Each passes through an RxJS wrapper such as fromEvent, fromEventPattern, from or webSocket into a layer of shared Observable services. Both the legacy UI and the new React UI subscribe to that layer

DOM and jQuery events. fromEvent accepts DOM elements and any object with jQuery-style on/off methods, so a jQuery object can be passed directly:

import { fromEvent, map } from 'rxjs';

// The legacy app fires: $(document).trigger('cart:updated', [cart])
export const legacyCartUpdated$ = fromEvent($(document), 'cart:updated', (_e, cart) => cart);

export const searchInput$ = fromEvent<InputEvent>(document.querySelector('#search')!, 'input').pipe(
  map((e) => (e.target as HTMLInputElement).value),
);
Enter fullscreen mode Exit fullscreen mode

Backbone, Ember and anything with its own add/remove API. fromEventPattern takes an add handler and a remove handler. This way, the Observable removes its listener from the legacy object properly when it is unsubscribed:

import { fromEventPattern, startWith, map } from 'rxjs';

// Backbone model or collection
export const fromBackbone = <T>(model: Backbone.Model, event = 'change') =>
  fromEventPattern<Backbone.Model>(
    (handler) => model.on(event, handler),
    (handler) => model.off(event, handler),
  ).pipe(startWith(model), map((m) => m.toJSON() as T));

// Ember object property (Ember.addObserver / removeObserver)
export const fromEmberProperty = <T>(obj: object, key: string) =>
  fromEventPattern<T>(
    (handler) => addObserver(obj, key, handler),
    (handler) => removeObserver(obj, key, handler),
  ).pipe(map(() => get(obj, key) as T), startWith(get(obj, key) as T));
Enter fullscreen mode Exit fullscreen mode

Two points are important here. First, convert to plain objects at the boundary (toJSON(), get). If the stream emits Backbone models or Ember records, the new code will also start calling .get('title'), and the legacy model will spread into React. This is the anti-corruption layer from our strategies post, applied to streams. Second, startWith gives the current value to late subscribers. So a React component that mounts later will not show an empty screen while it waits for the next change.

Promises, callbacks and sockets also have direct wrappers:

import { defer, bindNodeCallback } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';

// defer: the request only starts when someone subscribes, and runs again per subscriber
export const loadOrders$ = (userId: string) => defer(() => legacyApi.getOrders(userId));

// Node-style callback API from an old SDK
export const readConfig$ = bindNodeCallback(legacySdk.readConfig);

export const prices$ = webSocket<PriceTick>('wss://example.com/prices');
Enter fullscreen mode Exit fullscreen mode

For requests, prefer defer(() => promise) over from(promise). A promise starts running as soon as it is created. defer waits until someone subscribes, and this is what makes cancellation and retries possible later.

Step 2: Put Shared State Behind a Stream

Wrapping the sources makes the old app observable. The next step is to give each piece of shared state (the cart, the current user, the selected account, feature flags) a single owner. This owner is a small service that holds the value and exposes it as an Observable. Both apps read from this service, and all updates go through its methods.

import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';

export interface Cart { items: CartItem[]; total: number }

const state = new BehaviorSubject<Cart>({ items: [], total: 0 });

export const cartService = {
  cart$: state.asObservable(),
  count$: state.pipe(map((c) => c.items.length), distinctUntilChanged()),
  get value() { return state.getValue(); },

  add(item: CartItem) {
    const items = [...state.value.items, item];
    state.next({ items, total: sum(items) });
  },
};

// Keep the legacy event working for screens that still listen to it
cartService.cart$.subscribe((cart) => $(document).trigger('cart:updated', [cart]));
Enter fullscreen mode Exit fullscreen mode

A cart service in the middle holds a BehaviorSubject and exposes cart$ and an add method. On the left, a legacy jQuery header subscribes to cart$ and calls add from a click handler. On the right, a new React checkout page reads cart$ through a small useStream hook and calls add from a button. Both stay in sync because there is only one source of truth

The legacy side subscribes and updates the DOM the same way it does today:

// legacy/header.js (jQuery)
const sub = cartService.count$.subscribe((n) => $('#cart-badge').text(n));
$('#add-to-cart').on('click', () => cartService.add(readItemFromForm()));

// Backbone view: tie the subscription to the view's lifetime
initialize() { this.sub = cartService.cart$.subscribe((c) => this.render(c)); },
remove() { this.sub.unsubscribe(); Backbone.View.prototype.remove.call(this); },
Enter fullscreen mode Exit fullscreen mode

React reads the same stream through a small hook. useSyncExternalStore is the official React API for subscribing to an external store. It also avoids the "tearing" issue that a useEffect + useState version can have during concurrent rendering:

import { useSyncExternalStore } from 'react';
import type { Observable } from 'rxjs';

// Subscribe to a stream that always has a current value (like a service backed by a BehaviorSubject)
export function useStream<T>(stream$: Observable<T>, getValue: () => T): T {
  return useSyncExternalStore(
    (onChange) => {
      const sub = stream$.subscribe(onChange);
      return () => sub.unsubscribe();
    },
    getValue,
  );
}

// In a component
function CheckoutSummary() {
  const cart = useStream(cartService.cart$, () => cartService.value);
  return <p>{cart.items.length} items, {formatMoney(cart.total)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

That is the complete seam. A screen can move from the old app to React, and the cart badge in the legacy header will still update, because neither side owns the cart. Once the last legacy subscriber is removed, the service internals can be replaced (with Zustand, TanStack Query or plain React context) without changing any component, because components only depend on cart$ and add.

Issues we faced

  • Exposing the Subject directly. If the service exports state instead of state.asObservable(), any file can call .next(), and we are back to global events. Expose streams for reading and methods for writing.
  • Changing emitted objects in place. state.value.items.push(item) changes the object that both apps hold, but nothing is emitted. Always emit a new object, because useSyncExternalStore depends on reference changes.
  • Updates inside updates. A legacy handler that calls add() while handling a cart$ emission can create a loop. Using distinctUntilChanged on derived streams, and keeping the legacy re-trigger one-way, prevents most of these cases.
  • Loops with the legacy event. If the legacy app both fires and listens to cart:updated, either wrap it with fromEvent or re-trigger it from the service. Do not do both.

Step 3: Replace Hand-Written Async Code with Operators

Once events become streams, a lot of fragile legacy code can be rewritten in a few lines, and it usually becomes more correct too. A search box is the common example. Most legacy apps have some version of this jQuery code:

let timer, lastQuery;
$('#search').on('input', function () {
  clearTimeout(timer);
  const q = $(this).val();
  timer = setTimeout(() => {
    if (q === lastQuery) return;
    lastQuery = q;
    $.getJSON('/api/search', { q }).done(renderResults); // a slow early response can overwrite a newer one
  }, 300);
});
Enter fullscreen mode Exit fullscreen mode

It looks fine, but it has a race condition. If the request for "rea" is slow and the request for "react" is fast, the old results arrive last and replace the correct ones. The RxJS version avoids this by design, because switchMap unsubscribes from the previous request as soon as a new query comes in:

import { debounceTime, distinctUntilChanged, filter, switchMap, catchError, of } from 'rxjs';
import { ajax } from 'rxjs/ajax';

export const results$ = searchInput$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  filter((q) => q.length >= 2),
  switchMap((q) =>
    ajax.getJSON<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`).pipe(
      catchError(() => of([])), // one failed search shouldn't kill the stream
    ),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Marble diagram of a search box. Keystrokes r, re, rea, react on the input line. After debounceTime, only rea and react pass. switchMap starts a request for rea, then cancels it when react arrives, so only the results for react reach the screen. The hand-rolled version below lets the slow rea response arrive last and overwrite the correct results

ajax from rxjs/ajax cancels the actual HTTP request when it is unsubscribed. With fetch, pass an AbortSignal inside new Observable (or use fromFetch from rxjs/fetch) to get the same behavior.

The same small set of operators covers most of the other hand-written patterns we see in legacy apps:

Need Operator Why it is better than the legacy version
Only the latest request matters (search, filters, route params) switchMap Cancels old requests, so results never arrive out of order
Ignore clicks while a save is in flight exhaustMap Replaces isSaving flags and disabled-button workarounds
Every action must run, in order (queued writes) concatMap Writes do not mix, and no update is lost
Poll until the user leaves the page timer(0, 5000).pipe(switchMap(load), takeUntil(leave$)) Polls never overlap, and polling stops reliably
Retry flaky calls with backoff retry({ count: 3, delay: (_, i) => timer(2 ** i * 500) }) Limited attempts with a growing delay
Combine several sources combineLatest, withLatestFrom Replaces flags like if (userLoaded && settingsLoaded)
Time out a slow call timeout({ first: 8000 }) Configured in one place, and errors are reported properly

Polling needs one more example, because legacy polling code often causes duplicate requests and battery drain:

import { timer, defer, switchMap, takeUntil, retry, share, fromEvent, filter } from 'rxjs';

const hidden$ = fromEvent(document, 'visibilitychange').pipe(filter(() => document.hidden));

export const jobStatus$ = (jobId: string) =>
  timer(0, 5000).pipe(
    switchMap(() => defer(() => api.getJob(jobId))),
    retry({ count: 3, delay: 1000 }),
    takeUntil(hidden$),
    share(), // several widgets can watch the same job with one poller
  );
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Leaked subscriptions

Every subscribe() must be closed at some point. In legacy code, connect it to the lifecycle that already exists: a Backbone view's remove, an Ember component's willDestroy, an AngularJS $scope.$on('$destroy'), or a jQuery plugin's destroy. In React, the hook's cleanup takes care of it. For streams that should end on an event, keep takeUntil(destroy$) as the last operator in the pipe, so that nothing after it can subscribe again.

Hot and cold Observables

A cold Observable (like defer(() => fetch(...))) runs again for every subscriber. If three components subscribe to the same stream, three requests are made. When a result should be shared, add share() or shareReplay({ bufferSize: 1, refCount: true }). Use refCount: true unless the source really needs to run forever. Without it, shareReplay keeps the inner subscription (and its polling or socket) running even after the last subscriber has left.

Turning everything into a stream

A common mistake in RxJS projects is a codebase where a simple boolean flag becomes a BehaviorSubject<boolean> passed through four operators. Use streams only where time matters: events, sockets, polling, typeahead and state shared between the two apps. For normal server data in React (load a list, show it, refetch on change), TanStack Query is simpler and also handles caching. For local component state, use useState. A simple rule: if you cannot draw a marble diagram for it, it probably should not be an Observable.

Errors stop streams

When an error reaches a long-running stream, the stream ends and every subscriber stops getting updates, without any visible message. Catch errors inside the switchMap/mergeMap (as shown in the search example) so that the outer stream keeps running, and log every error you catch.

Bundle size and RxJS versions

RxJS 7 supports tree-shaking, and from version 7.2 the operators are exported directly from rxjs. So a typical bridge layer adds only a small amount to the bundle. If the legacy app still uses RxJS 5 or 6 (common in older Angular apps), the official migration guide explains the upgrade to 7. rxjs-compat was meant only for the 5 to 6 upgrade and should not be used in a new layer.

Testing the Bridge

Streams are easy to test without a browser. TestScheduler runs time-based operators on virtual time, so a 300 ms debounce does not actually wait 300 ms. Marble strings describe the input and the expected output:

import { TestScheduler } from 'rxjs/testing';

// The search stream, with its inputs passed in instead of read from globals
export const searchPipeline = (input$: Observable<string>, search: (q: string) => Observable<SearchResult[]>) =>
  input$.pipe(debounceTime(300), distinctUntilChanged(), filter((q) => q.length >= 2), switchMap(search));

const scheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));

test('search only queries the last term after a pause', () => {
  scheduler.run(({ cold, expectObservable }) => {
    const input$ = cold('a 100ms b 400ms c|', { a: 'rea', b: 'react', c: 'react' });
    const api = (q: string) => cold('--r|', { r: [`result for ${q}`] });
    // 'rea' is dropped by the debounce; 'react' settles at 401ms, its result arrives 2 frames later
    expectObservable(searchPipeline(input$, api)).toBe('403ms x 99ms |', { x: ['result for react'] });
  });
});
Enter fullscreen mode Exit fullscreen mode

Writing the pipeline as a function of its inputs is what makes it testable, and it is a good practice for the seam in any case. For checking that both apps behave the same, use the approach from our migration guides: one end-to-end test suite that runs the same screen in the old app and in the new app, and checks for the same visible result. The stream layer is what keeps those results the same, so it needs good test coverage.

Temporary Bridge or Long-Term Layer?

At the end of the migration, decide what happens to the RxJS layer. Both options below are fine, as long as the team makes a clear decision:

  • Temporary bridge. The bridge was added so that old and new code could share state. Once the legacy app is removed, plain React state, TanStack Query or a small store can handle most of it. Remove the Subjects, and keep only the few streams that represent real events.
  • Long-term layer. Apps with a lot of real-time behavior (trading screens, live dashboards using sockets, collaborative editors, IoT consoles) often keep RxJS as the event layer under React. In that case, document the rules: where streams are kept, how errors are handled, and which shared streams use shareReplay.

Checklist for retiring the RxJS bridge. Every legacy subscriber removed. Legacy event re-triggers deleted. Each service's Subject either replaced by a React-native store or kept and documented as a real event stream. No fromEventPattern wrappers left pointing at legacy objects. Marble tests moved to the new implementation or deleted with it

Whichever option you choose, delete the fromEventPattern wrappers around legacy objects in the same sprint as the last legacy screen. These wrappers are always temporary.

Further in This Series

This post covers one technique within a larger migration plan. The posts below explain the overall plan and each starting framework in more detail:

Our Migration Plugins

Finding every event source, observer and shared service in a legacy app takes the most time, and it decides where the streams should go. We have packaged our approach as free, open-source plugins for Claude Code, one for each starting framework:

Plugin Migrates from
ember-to-react Ember Classic and Octane
angular-to-react Angular 2+ and AngularJS 1.x, including hybrid ngUpgrade apps
backbone-to-react Backbone.js, Marionette, jQuery templates
jquery-to-react jQuery apps, plugins and jQuery UI
vanillajs-to-react Plain HTML, CSS and JavaScript

Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan (strategy, units, route map, test plan and risks) without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:

/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install backbone-to-react@cbx-plugins
Enter fullscreen mode Exit fullscreen mode

They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.

About MigrateX

MigrateX

MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like which shared state needs a stream-based seam and which can be moved directly.

A MigrateX project usually runs like this:

  1. Assessment. We map every route, shared service, event source and hidden dependency in your app, so you see where the work is before anyone agrees on a timeline.
  2. Plan. With your team, we choose the strategy, the slice order, and what can simply be deleted.
  3. Migration, step by step. Automation handles the repetitive parts. Our engineers handle the router, the seams and the code that connects old and new.
  4. Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
  5. Handover. We remove the temporary code, the seams and the flags, and leave your team with a codebase they understand and own.

Your app keeps running and shipping features the whole time, so there's no big launch day. Our migratex-examples repo has small apps in each legacy framework next to their migrated versions.

Working on the async code of a legacy app? Share a few details with us through Contact Us, and we will start with a free discussion on where the seams should go.

References

RxJS

React and migration patterns

Source code


Originally published on the CobuildX blog.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to