Backbone gave you models, views, a router and events, and left the rest to you. So most Backbone apps carry a homegrown framework on top: a global singleton, an event bus, a navigation controller, view cleanup code, a template build step and a drawer of jQuery plugins. Moving to React is mostly about finding that framework and deciding what replaces it. This guide covers how to run Backbone and React side by side, let React read Backbone models, turn views into components, test an app that never had tests, and delete the code that only existed to keep Backbone tidy.
Backbone never tried to be a full framework. It gave you models, collections, views, a router and events, and then it left the rest to you. How views get cleaned up, how one part of the app talks to another, where navigation lives, how templates get built: your team decided all of that. Or, quite often, someone who left the company years ago did.
That's why leaving Backbone feels different from leaving other frameworks. You aren't really moving off Backbone. You're moving off the framework your team built on top of it, and nobody ever wrote that one down. The good news is that a lot of it can simply be deleted, because React already does those jobs for you. The real work is finding it all first.
Source code: the examples in this post come from a small GitHub viewer we built with Backbone 1.0, RequireJS, jQuery Mobile and Handlebars. On a desktop its views sit side by side as panels, and on a phone each one becomes its own page. We rebuilt it in React 18 with the same screens, element ids and API calls. You can find both versions at cobuild-tech/migratex-examples. The app was small, so we simply rewrote it. For bigger apps, we move step by step, and that's the approach this post describes.
Before You Start
Why leave, and when not to
Nobody we talk to is upset with Backbone. It did exactly what it promised. Teams leave because of everything that grew up around it. RequireJS, Handlebars 1.x and old jQuery plugins have gone quiet, and in our example, jQuery Mobile was officially deprecated by its own maintainers in 2021. Every Backbone app also has its own house rules, so new developers have to learn your particular setup before they can change a button. And almost every Backbone team has a story about a "zombie" view that was removed from the page but kept listening to events.
That said, be honest with yourself before you commit. If the app is stable, rarely changes and the people who look after it are happy, leaving it alone is a perfectly fair choice. Move when changing the app keeps getting more expensive, not just because the code looks old.
Inventory the framework you built
Before anyone gives an estimate, list every piece of setup your team added around Backbone. Even our small example had a surprising amount. For each item, you'll decide whether React already covers it or whether it can simply go:
| What the team built | What it was for | In our example |
|---|---|---|
| A global singleton | Lets any module reach the controller, the event bus or the current user |
Globals, created to break a circular RequireJS dependency |
| An event bus | Lets distant parts of the app talk without imports | Globals.events.trigger('page:destroy', id) |
| A navigation controller | Decides which view goes where, often instead of Backbone.Router
|
controller.js with goToHomePage, goToRepoPage... |
| View cleanup code | Unbinds events and removes DOM so views don't leak |
BaseView.close() plus a page stack in events.js
|
| A template build step | Precompiles templates into JavaScript | The Handlebars CLI, run by hand from a README |
| Global AJAX hooks | Loading spinners, auth headers, error handling for every request |
$.ajaxSetup showing "Connecting To Github" |
| jQuery plugins | Widgets, animation, layout | jQuery Mobile pages and transitions, EnquireJS |
Here's a quick way to get a rough size. Search the code for .extend(, listenTo(, .on(, trigger(, $( and the name of your global object, and count the hits. The extend count tells you how many models and views you have. The event and jQuery counts show how much hidden wiring sits between them, and that's where most of the time goes.
Get onto a modern bundler first
If the app still loads through RequireJS or a long list of <script> tags, change that first, before any React code arrives. The nice part is that you don't need to rewrite your modules to do it. webpack understands AMD define() calls, so the existing code can be bundled as it is and moved to ES modules file by file later. Vite works really well for the new React code. While you're at it, move any hand-run template build step into the bundler, so it runs on every build.
Then upgrade Backbone to 1.4 or later. It's a small step that brings old patterns to the surface early. Our example still read this.options inside views, which Backbone removed in 1.1. It's also a good time to swap model.on(...) for this.listenTo(model, ...), so views clean up their own listeners when they're removed.
Running Backbone and React Together
Rewriting everything at once is tempting, but it's risky. The safer path is Martin Fowler's strangler fig approach: you add new code around the old app, piece by piece, until the old app isn't needed anymore. Backbone makes this easier than almost any other framework.
Mounting React inside a Backbone view
A Backbone view is really just an object that holds a DOM element, so you can hand that element straight to React with createRoot:
// views/cartView.js
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { Cart } from '../react/Cart';
export const CartView = Backbone.View.extend({
render() {
this.root ??= createRoot(this.el);
this.root.render(createElement(Cart, { cart: this.model }));
return this;
},
remove() {
this.root?.unmount();
return Backbone.View.prototype.remove.call(this);
},
});
The rest of the Backbone app doesn't notice anything changed. It still creates a CartView and calls render() and remove() as before. Calling render() again simply updates the React component with new props. Just remember to override remove() as shown, so the React root is cleaned up along with the view.
Sharing models and the URL
For a while, your React components will need data that still lives in Backbone models. There's no need to copy it into React state and keep two versions in sync. Backbone models already fire change events, and that's all React's useSyncExternalStore hook needs:
export function useModelValue(model, key) {
const subscribe = useCallback((onChange) => {
model.on(`change:${key}`, onChange);
return () => model.off(`change:${key}`, onChange);
}, [model, key]);
return useSyncExternalStore(subscribe, () => model.get(key));
}
Now a Backbone view can call cart.set('count', 3) and the React component updates on its own. One simple rule keeps this smooth: when an attribute holds an object or an array, always set a new one instead of changing it in place. React compares values by reference, so a new value is how it knows something changed.
The URL needs a single owner too. Backbone.history and React Router both listen to the URL, so pick one at a time. Early on, Backbone keeps the router and React lives in small islands. Once most screens are in React, switch it around: React Router takes over, and the remaining Backbone screens are mounted by small React wrapper components. From there, you can move routes across one at a time.
Replacing Models and Collections
A Backbone model looks like one thing, but it does several jobs at once. Split them up and each one has a simple React replacement:
| What a Backbone model does | A good choice in React |
|---|---|
url, fetch(), save()
|
Plain fetch functions, called through TanStack Query
|
parse() |
A tested function that shapes the API response |
defaults, validate()
|
Form state with a validation function or schema |
change events |
Query cache updates, with React re-rendering for you |
Collection reset, add, remove events |
Refetching or updating the cached list |
_.where, _.filter on collections |
Everyday array methods, used during render |
The parse() methods are worth more than they look. They hold years of knowledge about your API, like which field was renamed or which endpoint wraps its results. Move them into plain functions with tests before anything else, so both apps can use them. Ours was a one-liner, which is pretty typical:
// Backbone
const UserModel = Backbone.Model.extend({
urlRoot: 'https://api.github.com/legacy/user/search/',
parse: (data) => data.users[0],
});
// React
export async function fetchUser(name) {
const data = await getJson(LEGACY_USER_SEARCH_URL + encodeURIComponent(name));
return data.users[0] ?? {};
}
You might spot two small improvements in the React version: it encodes the username for the URL, and it returns an empty object instead of undefined when nothing matches. Porting is a great time to fix little bugs like these, as long as you write each one down.
One more thing to keep in mind. Backbone views often created a new collection every time they were shown, so every click sent a fresh request. TanStack Query caches results, which is usually a nice speed boost. Where users expect a button to really refresh, add a request id to the query key so it fetches again, just like before. That's what we did in our port.
From Views to Components
This is the biggest change in how you think, so let's look at a real example. Here's our Backbone home view reacting to a user search. It finds DOM elements, changes them and runs a jQuery animation:
showAdditionalButtons: function() {
if (this.model.has('username')) {
this.avatar.attr('src', gravatarUrl(this.model.get('gravatar_id')));
this.avatarContainer.slideDown();
} else {
this.avatarContainer.slideUp('slow', function() {
alert('No User Found');
});
}
}
In React, the handler just updates state. The markup describes what each state looks like, and the slide becomes a CSS transition on a class:
onSuccess: (user) => {
if (user.username) {
showAvatar(gravatarUrl(user.gravatar_id ?? ''));
} else {
hideAvatar();
setTimeout(() => alert('No User Found'), SLIDE_MS);
}
}
<div className={`avatar-container${avatarOpen ? ' is-open' : ''}`}>
Once the team gets used to this, a whole group of bugs goes away. The page can't fall out of step with the data anymore, because the page is always drawn from the data.
The rest of a view carries over easily. The events hash becomes onClick and similar props on the elements themselves. Our views even had two versions of every event map, one for touch and one for click, and React only needs one. Handlebars templates become JSX, and the {{#if isPhone}} blocks that wrapped every template became a single <PhonePage> component.
Delete the cleanup code
This is the most satisfying part. Our example had a page stack, a page:destroy event, a close() method on a base view, and careful calls to undelegateEvents() and removeData(), all to stop views from leaking. In React, when a component is no longer rendered, it's gone, along with its event handlers. So we deleted all of that code instead of porting it. It even fixed a bug: clicking "Get User Activity" twice on desktop used to leave the first view attached to the page.
The global event bus usually goes the same way. For each trigger, ask who actually listens to it. Most of the time it's just one place, and the event becomes a prop, a callback or a shared piece of state. In our app, page:destroy didn't need a replacement at all, because it only existed to clean up views, and React handles that for you.
Responsive layouts without jQuery Mobile
Our example had one more job to move. The same views showed up as panels on a desktop and as separate pages on a phone. The Backbone controller used EnquireJS to check the screen size once, at startup. In React, the screen size is just another value to subscribe to, using matchMedia and the same useSyncExternalStore hook we used for models:
const PHONE_QUERY = 'all and (max-width: 599px)';
function subscribe(onChange) {
const mql = window.matchMedia(PHONE_QUERY);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
export function useIsPhone() {
return useSyncExternalStore(subscribe, () => window.matchMedia(PHONE_QUERY).matches);
}
Now the panels and the pages share the same components. On a desktop, the home page shows them side by side. On a phone, each one is its own route with a Back header, so the browser's Back button works too, which the original never managed. Because the hook follows the window size, resizing switches the layout straight away. The jQuery Mobile look, with its header bars, rounded buttons, slide transitions and loading overlay, came across as a few hundred lines of plain CSS.
Testing the Migration
Many Backbone apps have few tests, and ours had none. So before changing anything, we wrote down what the app actually does, odd parts included. Michael Feathers calls these characterisation tests in Working Effectively with Legacy Code: they record what the code does today, not what anyone thinks it should do. Reading our app closely turned up things nobody would have put in a spec:
- If the user search fails, nothing happens at all, because the listener only ran on success.
- "No User Found" waits until the avatar has finished sliding away before it appears.
- Clicking "Get User Repositories" again clears the repo table as well as reloading the categories.
- The phone or desktop layout was decided once and never changed when the window was resized.
Each one became a test, or a written decision to change it. Without that list, the first three would have quietly changed during the port and come back months later as bug reports.
For bigger apps, write the important user journeys as Playwright tests and get them passing against the Backbone app first. Then run the same tests against React. A journey counts as migrated when it passes on both. Keeping the original element ids in the React markup gives both apps the same test handles, and mocking the API with MSW lets one fake server work for both apps. Inside the React test suite, wait for what the user would see with Testing Library's findBy queries. Our 23 tests cover the desktop panels, the phone pages and resizing across the breakpoint.
Finishing the Migration
Helping the team settle in
Code moves faster than habits, and Backbone habits run deep. These patterns show up in almost every team's first React pull requests, and each one has a simple React-friendly alternative:
- Reaching for jQuery inside an effect. If something on the page should look different, update state and let the component describe the new look.
- Rebuilding the event bus. Passing a callback, or sharing the state both components care about, keeps things easy to follow.
- Changing objects in place and forcing a render. Create new values instead, and React will pick up the change on its own.
- Using effects to keep two values in sync. Usually the second value can just be calculated during render, as the React docs on effects explain.
A short internal page that shows one of your own Backbone views next to its React version helps more than any general tutorial. Reviewing early React pull requests in pairs, with one person who knows the old app and one who knows React, spreads both kinds of knowledge quickly.
The removal checklist
You'll know you're finished when backbone, underscore, jquery and the template runtime are removed from package.json, along with their plugins. Before that commit, run through a few quick checks:
- Every screen is rendered by React, and
Backbone.historyis no longer started. - Everything the global singleton used to hold has a new home, or was removed on purpose.
- Auth headers, loading indicators and error handling from
$.ajaxSetupandajaxPrefilternow live in your fetch client. - Plugin CSS, like jQuery Mobile's, has been replaced, and nothing depends on its class names.
- The bridge code, like React roots inside Backbone views and the model hook, has been cleaned up.
- Old URLs, including hash URLs like
#users/42, still work or redirect.
That last point is easy to miss. Many Backbone apps used hash URLs (/#users/42), and those links live on in bookmarks, emails and help pages. The part after the # never reaches the server, so a server redirect can't catch it. Add a small bit of code on the React side that reads the old hash and sends users to the new path, and keep it around for a long time. Also keep the last Backbone build ready to deploy behind a flag for a short while. Once real traffic has been quiet long enough, delete it and enjoy watching jQuery leave the bundle.
Looking back, a Backbone migration is mostly about finding everything your team built because Backbone didn't, and noticing how much of it React makes unnecessary. In our example, the page stack, the event bus, the view cleanup and the global singleton were all deleted rather than ported. In short: list the homegrown framework before you estimate, get onto a modern bundler first, mount React inside Backbone views, and write down what the app really does before you test both versions against it.
About MigrateX
MigrateX is our software and services platform for moving software, data and infrastructure from one technology to another. It brings automation and experienced engineers together, so the repetitive work gets done quickly and the tricky decisions get the human attention they need.
Everything in this guide comes from how we run Backbone to React projects with MigrateX. Here's what that looks like in practice:
- Assessment. We scan your Backbone app and map the framework your team built around it: globals, event buses, controllers, cleanup code, template builds, AJAX hooks and jQuery plugins. You get a clear picture of where the effort really sits before anyone commits to a timeline.
- Migration plan. Together with your team, we decide what each piece becomes in React, what can simply be deleted, which screens move first, and who owns the URL along the way.
-
Step-by-step migration. Automation handles the repetitive parts, like moving the build to a modern bundler, converting templates to JSX and porting
parse()methods. Our engineers handle the parts that need judgement, like the data layer, routing and the views that bridge the two apps. - Proof that it works. We write down how your app behaves today, oddities included, and run the same Playwright tests against both apps. A screen only counts as migrated when it passes on both.
- Handover and clean-up. We remove the bridge code, jQuery and the last Backbone packages, keep a way back until traffic is quiet, and leave your team with a React codebase they know and own.
What you get along the way:
- No big-bang release. Your app keeps running and shipping features while the migration happens in the background.
- Less risk. Shared tests, careful URL handling and a way back mean users don't notice the move, and you can pause at any point.
- A team that's ready. We pair with your developers throughout, so they're comfortable with React long before the last Backbone view is gone.
- More than front ends. The same approach covers other frameworks, like Angular and Ember, as well as data and infrastructure migrations.
To see the code behind this guide, visit github.com/cobuild-tech/migratex-examples. Still running a Backbone app that's been quietly doing its job for years? We'd love to hear about it. Tell us a little about your app through Contact Us, and we'll start with a free conversation about where the effort is likely to be.
References
Source code
- CobuildX AI. migratex-examples: a responsive GitHub viewer built with Backbone 1.0, RequireJS, jQuery Mobile and Handlebars, and its React 18 rewrite, with a README describing the migration. https://github.com/cobuild-tech/migratex-examples/tree/main/backbone-react
Migration strategy
- M. Fowler. StranglerFigApplication. martinfowler.com, 29 June 2004. https://martinfowler.com/bliki/StranglerFigApplication.html
- M. Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004.
Backbone and its stack
- Backbone.js documentation. https://backbonejs.org/
- Backbone.js. Change log (1.1.0 removed automatic
this.options). https://backbonejs.org/#changelog - webpack. Module methods: AMD. https://webpack.js.org/api/module-methods/#amd
- jQuery. jQuery maintainers continue modernization initiative with deprecation of jQuery Mobile. 7 October 2021. https://blog.jquery.com/2021/10/07/jquery-maintainers-continue-modernization-initiative-with-deprecation-of-jquery-mobile/
React and testing
- React. createRoot. https://react.dev/reference/react-dom/client/createRoot
- React. useSyncExternalStore. https://react.dev/reference/react/useSyncExternalStore
- React. You Might Not Need an Effect. https://react.dev/learn/you-might-not-need-an-effect
- React Router. https://reactrouter.com/
- Playwright. https://playwright.dev/
- Testing Library. Async methods (
findByqueries). https://testing-library.com/docs/dom-testing-library/api-async/ - MDN. Window: matchMedia() method. https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia
Libraries
- TanStack Query. https://tanstack.com/query/latest
- MSW (Mock Service Worker). https://mswjs.io/
- Vite. https://vitejs.dev/
Originally published on the CobuildX AI blog.








Top comments (2)
i've been using an rxjs subject to replace backbone's event bus and it meshes nicely with react's unmount lifecycle, how do you handle view cleanup when the component unmounts?
In React, we keep each subscription inside a useEffect and call sub.unsubscribe() in the cleanup... That gives us pretty much the same lifecycle we had with Backbone's listenTo/stopListening... While Backbone and React are still mixed, the Backbone view hosting the React island owns the React root, so its remove() calls root.unmount() first, then undelegateEvents() and stopListening()... Unmounting React first makes sure all of its cleanup runs before Backbone removes the element from the DOM...
We also moved away from the global page:destroy event and just call remove() explicitly now. That makes the ownership and teardown much easier to follow instead of having things get cleaned up somewhere unexpectedly...
One other thing we found along the way: a plain Subject works nicely for actual events, like showing a toast or triggering a logout... But for values that components need to read, such as the current user or cart, we use a BehaviorSubject or a small store with useSyncExternalStore... That way, if a component mounts later, it can still get the current value instead of having to wait for the next event...