Switching from backbone to react router

Wercker is a Docker-Native CI/CD Automation platform for Kubernetes & Microservice Deployments

Jacco Flenter
Jacco Flenter
August 16, 2016

A bit over a year ago we decided to make the switch from Backbone.js to React.

We've  finally made the jump and switched the routing and the rendering of the site to React (or packages from that ecosystem).

In this post we’ll be looking into some of the hurdles we encountered while moving our 63 routes over, to a new system using React Router. We’ve been using Backbone for a long time however our setup wasn’t ideal:

  • the main routing file was over 600 lines of code
  • it was still using ’#’ (hashes) in the urls
  • all our new code is written in react, ideally we’d use a dedicated package/ solution for routing (so we can move away from jQuery/underscoreJS/BackboneJS)

The workload

Before we look at the code as it was, let’s quickly look at the work that we know needs to be done:

  • create basic routing structure
  • create wrapper for React that will manage the legacy Backbone views
  • allow navigation to work with react-router without page reloads
  • port over all of our existing routes & add details like: scroll to top (on navigating) & analytics.

And finally, it’s good to be aware of two things:

1. local modules

Our front-end code consists of smaller (ideally) stand-alone modules who live in amodules folder. These will normally be imported using absolute paths, for example modules/awesome-code.

2. Related tools/packages

Besides React we’re also using the following packages/tools and may be referring to them throughout the article:

  • webpack (version 1.x)
  • babel. Most of our examples use es6 syntax (version 6.x)
  • redux (version 3.x)

The starting point

In the past year, each time new functionality was added (such as Workflows) the new code would be a stand alone module. These modules would have a Backbone view that would function as a wrapper for the React view. This was done so our router wouldn’t need to support both Backbone & React based views.

// The backbone [react] wrapper
import { View } from 'backbone';
import reactDom from 'react-dom';

const ReactWrapper = View.extend({
  props: {},

  /**
   * Constructor
   *
   * @param {Object} with the following properties el and props (optional)
   */
  initialize(options) {
    this.options = options || {};

    this.props = this.options.props || {};

    this.render();
  },

  remove() {
    reactDom.unmountComponentAtNode(this.el);
    View.prototype.remove.call(this);
  },
});

export ReactWrapper;

And an example view implementing this:

import { Provider } from 'react-redux';
import { ReactWrapper } from 'modules/generic-views';
import configureStore from './store';
import React from 'react';
import reactDom from 'react-dom';
import OrgContainer from './containers/organization';

const WrapperView = ReactWrapper.extend({
  store: null,

  render() {
    this.store = configureStore({});

    const { organization } = this.props;

    reactDom.render(
      <Provider store={this.store}>
        <OrgContainer
          organization={organization}
        />
      </Provider>,
      this.el
    );
  },
});

export default WrapperView;

Moving forward: what do we need for our hybrid Backbone/react app to work:

  • create basic routing structure
  • create wrapper for React that will manage the legacy Backbone views
  • allow navigation to work with react-router without page reloads
  • port over all of our existing routes & add details like: scroll to top (on navigating) & analytics.

To the new router!

The new routing system will be using react-router or more precisely: react-router-redux. React-router-redux isn’t required but will bring navigation actions to redux. So now navigating to a new page is visible via redux devtools or maybe using logging middleware. It’s a nice addition to react-router, now as to why react-router:

  • it supported everything we needed (pre/after hooks, middleware)
  • fairly well documented
  • by far the most popular solution. See this article for information on some alternatives

Next we looked into common practices on several popular boilerplate/starter kits. However we focussed mostly on these two:

What’s interesting: they both do lazy loading and both use plainRoutes instead of relying on jsx. PlainRoutes are defined as normal javascript objects. Normally javascript objects may be less then ideal, however since we intend to do a lot of lazy loading (which happens in functions) and in our eyes: JSX’s will look less nice. This is different from our components: where we use JSX for our code.

Here’s what a typical route definition looks like:

export default store => ({
  path: ':organization',

  indexRoute: {

    // Async getComponent is only invoked when route matches
    getComponent(nextState, cb) {

      // Webpack - use 'require.ensure' to create a split point
      // and embed an async module loader (jsonp) when bundling
      require.ensure([], require => {

        //  Webpack - use require callback to define
        // dependencies for bundling
        const OrgSettings = require('modules/org-settings-organization');

        // Return getComponent
        cb(null, OrgSettings);

      // Webpack named bundle
      }, 'organization');
    },
  },

  getChildRoutes(location, cb) {

    require.ensure([], require => {
      cb(null, [
        require('./teams')(store),
      ]);
    });
  },
});

The example shows that the route is a function, it returns an object with the following properties:

  • path (required, string). The pattern it should match.
  • indexRoute (optional, object). An object with in our case a getComponent property (function). It allows us to specify which view to show when you land on the url.
  • getChildRoutes (optional, function). A function that can load nested routes, this way we can keep the routes compact/small.

Require.ensure

In getComponent or getChildRoutes we’re relying on require.ensure to allow for asynchronous/lazy loading. With require.ensure we can also use a 3rd parameter (which is a string/name) to control which code is bundled together (see webpack’snamed chunks ). We’re not using the es6 system.import in our setup here because we’re relying on webpack 1.x Back to all the lazy loading: this setup allows us to have full control over when/what is loaded. To illustrate this: imagine a component that can display nice stats using something like D3, but a child route isn’t using that. Now the user isn’t required to wait for D3 to load (which is 76KB gzipped) to view that childRoute.

The React/Backbone wrapper

Next step was to have React component to manage the backbone view. It basically creates a div and during certain lifecycle events it will create/update/delete the backbone view. A reference to the view is stored on the instance. While testing an initial version of this view, we encountered our first snag: our backbone views would use Backbone.history.navigate(..., {trigger: false}) to update the url without refreshing the view. With react-router this is no longer the case. Not only because we’re no longer using backbone.history, but also because changes to the url will trickle down to the router and the rendered components.

This meant that componentDidUpdate would sometimes need to recreate the view and sometimes just rely on the backbone view to handle the new options. To that end backbone views can implement a ‘updateOptions’ method (which is a bit similar to React’s shouldComponentUpdate). It will be called with options as the parameter and if it returns true, the wrapper won’t recreate the view. Here’s an example implementation:

// The backbone [react] wrapper
import { View } from 'backbone';
import reactDom from 'react-dom';

const ExampleView = View.extend({
  /**
   * Updates view according to new options. Returns false if it's unable to
   * handle the change (the backbone-wrapper will then recreate the backbone
   * view)
   * @param  {Object} options
   * @return {Boolean}
   */
  updateOptions: function(options) {
    var sameProject = options.projectId === this.projectId;

    if (sameProject) {
      return true;
    }

    return false;
  },
  ...
}

Here’s a full gist of the backbone-wrapper

Navigation

All of the backbone views as well as the html outside of React use normal anchor elements to link to pages on our site. Before switching we used ’#’ based links for anything that was part of our app. In the new setup we’re no longer using that so we need to stop the browser from refreshing. To fix this we’ve added the following functionality/code:

  1. a module/navigate module that’ll be used in place of Backbone.history.navigate() calls
  2. a generic handler that checks that if a link is clicked, whether it should prevent the default behavior and use the navigate module

The navigate module

This module nicely decouples our code from the react-router and also allows us to get warnings when navigate is called with the now no longer supportedtrigger: false option:

import { browserHistory } from 'react-router';

const DEFAULT_OPTIONS = {
  replace: false,
};

function navigate(uri, options = DEFAULT_OPTIONS) {
  if (process.env.NODE_ENV === 'development' && options.trigger === false) {
    console.warn('navigate with options.trigger: false is not supported');
  }

  if (options.replace) {
    browserHistory.replace(uri);
  }

  browserHistory.push(uri);
}

export default navigate;

Generic handler

This is probably the most dubious piece of code, since it’s a very generic solution which uses jQuery (a dependency we eventually want move away from) and will respond to clicks on any anchor on the page. That said, it got things working fairly well and is still here. Here’s the code:

// Important:
// generic handler to fix page refreshes when navigating on current site
//
// anchors with the data-bypass-router attribute:
//    Links that remain on the same domain but should not trigger a react-
//    router navigation, can use the data-bypass-router attribute to bypass
//    the router and trigger a page refresh/request to server
jQuery('body').on(
  'click.routing',
  'a[href]:not([data-dropdown],[href^=http], .tab-title a, [data-bypass-router])',
  function(evt) {
    // Don't take over navigation when ctrl/meta key is pressed. This is to
    // allow users to open pages in new tabs/windows
    if (
      evt.ctrlKey ||
      evt.shiftKey ||
      evt.metaKey || // apple
      (evt.button && evt.button === 1) // middle click, >IE9 + everyone else
    ) {
      return;
    }

    evt.preventDefault();

    var opts = {};
    if (evt.currentTarget.dataset.replace === 'true') {
      opts.replace = true;
    }

    navigate(evt.currentTarget.getAttribute('href'), opts);
  }
);

We’re attaching a click listener on the body which needs to match a fairly complicated css selector. This is mostly because we’re using Zurb foundation as a basis and want to prevent navigation when clicking on tabs/drop-downs.

Take aways

There are several take aways for us from the migration:

don’t use backbone’s navigate(…, {trigger: false})

Like mentioned before, better stay away from this. It will cause the router to be out of sync with the router. This is not supported in react-router and though implementing updateOptions on the backbone view can solve this, but can be complicated for views that rely on several data sources.

React Router means you get data via props, not via the (redux) store

Last year, we imagined that future setup would have a store in which the props from the url would be stored and this object was sometimes passed along to nested components. It turned out not to be the case, this meant we needed to update more then just the top level containers.

Ensure your react app is able to load all data that isn’t in url/props

In our case we used to have Backbone views that created the React views, it’s easy/tempting to reuse data that’s already loaded by backbone.

Conclusion

In general we’re very happy with the switch, it brought us the following advantages:

  • routing that uses lazy-loading. The setup allows for a lot of tweaking to create the ideal build result.
  • having a persistent store (between routes): this means we don’t always have to always load data. We used backbone-fetch-cache in our backbone views, but the redux store will allow us to share data without having rely on additional libraries
  • Backbone can be phased out (and jQuery too at some point): smaller JS files!

That’s it for now. A small recap: there’s room for improvement however we’ve made the first major step and can’t wait to continue with the next steps. We are curious about your experience with similar transitions! Feel free to join our slack channel to discuss your setup/questions around this article!

Earn some stickers!

As usual, if you want to stay in the loop follow us on twitter @wercker or hop on ourpublic slack channel. If it’s your first time using wercker, be sure to tweet out your#greenbuilds and we’ll send you some swag!

 

Topics: Product, Containers