Wrapping a Marionette app in React

Working on a legacy JavaScript app powered by Backbone and Marionette, and willing to use React to develop new features? While that is most certainly feasible, integrating the former in the latter has its quirks, especially if there is routing involved on both sides.

Let's say your Marionette app looks something like the following:

import Marionette from "backbone.marionette";
import LayoutView from "./views/LayoutView";

export default Marionette.Application.extend({
// ...

onBeforeStart() {
this.layoutView = new LayoutView();
},

onStart() {
Backbone.history.start({
pushState: true
});
}
});

And LayoutView implicitly renders in a div#app element:

import { View } from "backbone.marionette";

export default View.extend({
el: "#app",

// ...
});

Chances are you will be willing to keep using this same element as the root of your brand new React app:

import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
import Wrapper from "./Wrapper";

ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<nav>
<Link to="/">Marionette app</Link>
<Link to="/new-route">New route</Link>
</nav>
<Switch>
<Route path="/new-route">
<div>Haha React go brrr</div>
</Route>
<Route path="/">
<Wrapper />
</Route>
</Switch>
</BrowserRouter>
</React.StrictMode>,
document.getElementById("app")
);

The Wrapper component will therefore be responsible for the initialization and encapsulation of the Marionette app:

import React, { useRef, useEffect } from "react";
import Backbone from "backbone";
import { useLocation } from "react-router-dom";
import MarionetteApp from "../backbone/app";

const app = new MarionetteApp();

export default function Wrapper() {
const wrapper = useRef(null);

useEffect(() => {
app.start({ el: wrapper.current });

// This will be called upon unmounting the component,
// i.e. navigating to a new, React-powered route
return () => {
app.destroy();
};
}, []);

// Ensure React links trigger Backbone navigation properly
useEffect(() => {
Backbone.history.navigate(location.pathname, { trigger: true });
}, [location.pathname]);

return (
<div ref={wrapper}></div>
);
}

Now, all that is left to do is to remove the el key from LayoutView, and modify the Marionette app accordingly:

import Marionette from "backbone.marionette";
import LayoutView from "./views/LayoutView";

export default Marionette.Application.extend({
// ...

onBeforeStart(app, options) {
this.layoutView = new LayoutView();

// Explicitly render inside the given node, i.e. our Wrapper component's
options.el.innerHTML = this.layout.render().$el;
},

// ...

onDestroy() {
this.layoutView.destroy();
Backbone.history.stop();
}
});

Implementing onDestroy as such ensures everything is cleaned up in order to start fresh when the user browses / again and your Marionette app is remounted; it will not work otherwise.

For this setup to be complete, you will also need to watch route changes on the Marionette side, in order to notify React Router accordingly. Your mileage may vary regarding how to do it; hopefully, you already are overriding Backbone.Router or Marionette.AppRouter and emitting an event you can subscribe to. Regardless, here is what you want to do in the Wrapper component:

// ...
import { useLocation, withRouter } from "react-router-dom";
// ...

function Wrapper() {
const wrapper = useRef(null);

useEffect(() => {
app.start({ el: wrapper.current });

// Propagate Backbone route changes to React
someBackboneRadioChannelOrWhatever.on("navigationEvent", () => {
history.push("/" + Backbone.history.fragment);
});

return () => {
someBackboneRadioChannelOrWhatever.off("navigationEvent");
app.destroy();
};
}, []);

// ...
}

export default withRouter(Wrapper);