DEV Community

Cover image for Migrating a Backbone.js app to React without a rewrite: Web Components as the bridge
Suman Kumar Khan
Suman Kumar Khan

Posted on Originally published at sumankumarkhan.com

Migrating a Backbone.js app to React without a rewrite: Web Components as the bridge

How I moved a Backbone.js and Marionette product toward React micro-frontends one screen at a time, using Web Components as the boundary both worlds understand.

At Thynk Web I worked on App Studio, a product built on Backbone.js, Marionette and jQuery. I led its migration toward React micro-frontends. The constraint that shaped everything: the product had to keep working, and keep shipping features, the whole time. A big-bang rewrite was never on the table. This post is about the approach — and why Web Components turned out to be the right bridge.

Why not rewrite

Rewrites look clean on a whiteboard and go badly in practice. The old app keeps changing while you rebuild it, behaviour that nobody documented gets lost, and for months you ship nothing users can see. The alternative is an incremental, strangler-style migration: new code grows around the old system, one piece at a time, until the old piece can be removed.

The hard question with incremental migration is the boundary. How does a Marionette view host a React component, pass it data and hear back from it, without either side knowing the other's internals?

Web Components: a boundary both sides speak

A Custom Element is just an HTML tag. Backbone and Marionette already know how to render HTML and listen to DOM events. React knows how to render into any DOM node. So a custom element that mounts a React tree inside itself gives you a contract both worlds understand natively:

  • Inputs go in as attributes or properties.
  • Outputs come out as DOM CustomEvents.
  • Lifecycle is handled by the browser through connectedCallback and disconnectedCallback.
// define-react-element.tsx
import { createRoot, type Root } from 'react-dom/client';
import type { ComponentType } from 'react';

export function defineReactElement<P extends object>(tag: string, Component: ComponentType<P>) {
  class ReactElement extends HTMLElement {
    private root?: Root;
    private props = {} as P;

    set data(value: P) {      // rich inputs as a property
      this.props = value;
      this.render();
    }
    connectedCallback() {
      this.root = createRoot(this);
      this.render();
    }
    disconnectedCallback() {
      this.root?.unmount();   // no leaked React trees when Marionette tears down
    }
    private render() {
      this.root?.render(<Component {...this.props} emit={this.emit} />);
    }
    private emit = (type: string, detail?: unknown) =>
      this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
  }
  if (!customElements.get(tag)) customElements.define(tag, ReactElement);
}
Enter fullscreen mode Exit fullscreen mode

On the Backbone side, the new screen is just markup and an event listener:

// stage-view.js
const StageView = Marionette.View.extend({
  template: () => '<app-stage-editor></app-stage-editor>',
  events: { 'stage:save app-stage-editor': 'onSave' },
  onRender() {
    this.$('app-stage-editor')[0].data = { stage: this.model.toJSON() };
  },
  onSave(e) {
    this.model.save(e.originalEvent.detail);
  },
});
Enter fullscreen mode Exit fullscreen mode

The Marionette view doesn't know React exists. The React component doesn't know Backbone exists. That's the point.

Tip: Pass objects through a property, not an attribute. Attributes are strings; serialising models into them is slow and loses types.

Migrating screen by screen

With the bridge in place, the order of work becomes a product decision instead of a technical one. Pick a screen, rebuild it in React behind a custom element, swap the tag in, and remove the old view once it's stable. Old and new code live side by side in the same build (we used Webpack) while the migration progresses.

A few rules kept this safe:

  1. Keep the model as the source of truth during the transition. The React component receives data and emits intentions; the existing model layer still saves.
  2. Make the contract small. One data property and a handful of named events per element. If a component needs ten inputs, the boundary is in the wrong place.
  3. Clean up on disconnect. Marionette re-renders and destroys views often. Unmounting the React root in disconnectedCallback prevents memory leaks and ghost listeners.

Warning: Watch styling leaks in both directions. Global legacy CSS will reach into your React markup unless you scope it, and new component styles can break old screens. Decide early whether to use Shadow DOM or a scoping convention.

Not just migration: fixing what was there

Migration work forces you to read the old code closely, and that surfaces bugs. On App Studio I resolved 90% of the API Builder bugs and did performance work on metadata and stage logic. I also shipped 5+ enhancements while the migration was ongoing — stage duplication, copy-paste, actions, rules and automation — which is the real argument for incremental migration: users kept getting improvements instead of waiting for a rewrite.

Components were built from Figma designs, and I documented the approach in Confluence so the rest of the team could build new screens the same way.

What I'd keep doing

  • Choose the boundary before the framework. The custom-element contract mattered more than any React pattern inside it.
  • Ship value during the migration. Features and bug fixes alongside the rewrite keep the business on board.
  • Write it down. A migration is a team effort over months; a short, clear guide is what makes the tenth migrated screen look like the first.

Web Components are often pitched as a way to build design systems. Their quieter superpower is being the one interface every framework — including a long-lived Backbone app — already agrees on.


Originally published at sumankumarkhan.com.

Top comments (0)