How We Rebuilt Our App, Part 2: From Rails + Marionette to React

SiftJune 9, 2015

In the first post of this series, we gave an overview of Sift Science’s architectural migration to React and Dropwizard. We followed up with some best practices for scaling React in a production setting and some tips on using React with D3. Today’s post will chronicle the front-end migration process of moving from Rails + Backbone + Marionette + Handlebars to a static Backbone + React console, and the challenges we encountered.

The Plan

From the outset, we decided on an incremental migration over a heads-down rewrite in isolation. There were a couple of primary motivations for doing it this way. We wanted to move forward in new product development without taking a couple months to rewrite our codebase, and without duplicating work between old and new architectures in the meantime. It would be beneficial to test out small chunks of new technology at a time and iterate upon them to arrive at something great, rather than release something entirely new into production in one go.

The drawbacks of this approach were also evident. We needed to write a nontrivial amount of temporary code and infrastructure to render views from two separate codebases. New code would also be littered with some vestiges of the transitional process that would need to be removed or refactored down the road. There would be an increased risk of bugs and regressions due to the coexistence of old and new codebases and unpredictable interactions between them.

Taking all of these things into consideration, we produced a roadmap for the incremental migration:

  1. Launch React app capable of rendering legacy Marionette views
  2. Build new products on Dropwizard and React
  3. Simultaneously migrate remaining views in legacy codebase to Dropwizard and React
  4. Finally, remove Rails foundation entirely – moving all forms, authentication, and session management into static javascript console.

We’re going to focus on steps 1 and 4, since they posed many interesting technological challenges without well-defined solutions.

Rendering Marionette and React together

The first critical step in the migration was to build a new app framework in React and make it capable of rendering legacy views (rather than try to stuff React components into our legacy app framework, which would lead to more buildup of legacy code). To accomplish this, we used the same wrapper for Marionette and React main page views:

In our Marionette app.js:

App.addRegions({ mainRegion: '.app-content' });
...
App.mainRegion.show(App.mainLayout);

We maintained a reference to the Marionette app in our new codebase. In React console_layout.jsx, our main wrapper for the console pages:

var MarionetteApp = require('../legacy/app'),
...
    componentWillReceiveProps: function() {
      // Clear any view that was previously rendered by Marionette
      MarionetteApp.mainLayout.$el.text('');
    },

    render: function() {
    ...
      return (
        <div className='app-content'>
          // If the page to show is a React page, the component will be
          // passed in as a child of the layout.
          // Otherwise, children will be null and Marionette
          // will render a page into .app-content.
          {this.props.children}
        </div>
      );
    }

All of the legacy Marionette page views were defined in modules that could be accessed from the Marionette app, and extended a PageRenderer helper that took care of rendering the page into the shared layout wrapper, which allowed our router to render both legacy pages and React pages.

In router.js:

var MarionetteApp = require('../legacy/app'),
  ...
  lists: function() {
    // Render a new React page
    this.app.setProps({page: 'lists'});
  },
  settings: function() {
    this.app.setProps({page: 'settings'});
    // Render a legacy Marionette page inside app-content
    (new MarionetteApp.SettingsPage.PageRenderer()).showPage();
  }

One complication we ran into: due to React and Marionette sharing the same DOM element wrapper, if state change occurred higher up that caused React to unmount and remount the wrapper component, then the Marionette app would lose its reference to it. To solve this, we removed the region from the Marionette app, added it back, reinitialized the app, and triggered a Backbone refresh.

MarionetteApp.removeRegion('mainRegion');
MarionetteApp.addRegions({mainRegion: '.app-content'});
MarionetteApp.mainRegion.show(App.mainLayout);
Backbone.history.loadUrl(Backbone.history.fragment);

Taking Rails out of the Picture

We were using Rails/Devise for many foundational aspects of the Backbone + Marionette console: authentication, session management, and validation for new account creation.

Authentication

Authentication for our new static console would rely on the OAuthToken resource we built on top of Apache Oltu. On the Javascript end of things, we embed a valid access token in all API requests. We made an Oauth module that configured all AJAX requests when the App started up. From oauth.js:

$.ajaxSetup({
  beforeSend: function(jqXHR) {
    if (LocalStorage.get('access_token')) {
      jqXHR.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.get('access_token'));
    }
  }
});

Session Management

We also needed to gracefully deal with OAuth token expiry and refresh – it should be invisible to the user when this occurs (unless the TTL has expired and they should be prompted to login again). We handled this issue by intercepting error callbacks to all API requests – if a particular request fails due to an expired token, we suppress its error function (so the user doesn’t see any failed request handling in the UI) and instead add it to a request queue and trigger an access token refresh request. If the refresh succeeds, we replay the request queue in place – without affecting the user’s experience. If it fails, then the client must have exceeded the TTL of the refresh token, so we simply boot them to the login page with a return_to in the URL. From oauth.js:

$.ajaxPrefilter(function(opts, originalOpts, jqXHR) {
  opts.error = getAuthErrorCallback(opts, opts.error);
});

function getAuthErrorCallback(opts, originalErrorCallback) {
  return function(jqXHR, textStatus, error) {
    if (jqXHR.status === statics.StatusCodes.UNAUTHORIZED) {
      self._queuedRequests.push(opts);
      if (!self._refreshTriggered) {
        self._refreshTriggered = true;
        self.refresh();
      }
    } else {
      if (_.isFunction(originalErrorCallback)) {
        originalErrorCallback(jqXHR, textStatus, error);
      }
    }
  };
}

Redirection

Finally, if a user has not authenticated and tries to visit a page that requires authentication, or has authenticated and tries to visit an anonymous page (e.g. the login or signup page), we need special redirection logic in our router to handle what Rails previously did. We added a Gatekeeper to our Backbone router.js to serve this purpose:

var Gatekeeper = {
  anonymous: function(fn) {
    return function() {
      if (!this.app.anonymous) {
        this.navigate('/console', {trigger: true, replace: true});
      } else {
        fn.apply(this, arguments);
      }
    };
  },

  requiresLogin: function(fn) {
    return function() {
      if (this.app.anonymous) {
        this.navigate('/console/login?return_to=/' + Backbone.history.fragment, {
          trigger: true,
          replace: true
        });
      } else {
        fn.apply(this, arguments);
      }
    };
  }
};

These Gatekeeper functions wrap the normal Backbone routing functions:

var Router = Backbone.Router.extend({
  ...
  lists: Gatekeeper.requiresLogin(function(params) {
    ...
  }),

  login: Gatekeeper.anonymous(function(params) {
    ...
  })
});

It’s very easy to add more rules to the Gatekeeper, and the implementation allows multiple rules to wrap each other as well – for example, if a particular route requires both login and Sift Science admin privelges, we can simply add another Gatekeeper and drop it in:

superSecretAdminPage: Gatekeeper.requiresAdmin(Gatekeeper.requiresLogin(function() {
  ...
}));

Forms

Previously, we also relied heavily on Rails and Devise to validate our forms and provide error messages back to the client. To solve this problem in a Rails-less world, we came up with a BackboneForm component that allows us to handle all aspects of form submission declaratively in React, including layout, mapping of form fields to the BackboneModel (via input name), submission, client-side validation, and server-side validation messaging. For example, our signup form looks like this:

<BackboneForm
  ref='backboneForm'
  autofocus={true}
  model={self._signupModel}
  loadingText='Creating account'
  onSave={self.onSignupSuccess}
  onError={self.onSignupFailed}
  layout={[
    [
      {
        type: Form.FieldTypes.RENDERABLE,
        render: function() {
          return <FormLabel value='SITE ADDRESS' />
        },
        gridWidth: 4
      },
      {
        inputs: [{
          name: 'site',
          displayName: 'site address',
          validators: [
            Validators.notEmpty(),
            Validators.validDomain()
          ],
          placeholder: 'www.example.com'
        }],
        type: Form.FieldTypes.TEXT_FIELD,
        gridWidth: 8,
        disabled: !!channelSite
      }
    ],
    [
      {
        type: Form.FieldTypes.RENDERABLE,
        render: function() {
          return <FormLabel value='EMAIL ADDRESS' />
        },
        gridWidth: 4
      },
      {
        inputs: [{
          name: 'email',
          displayName: 'email address',
          validators: [
            Validators.notEmpty(),
            Validators.validEmail()
          ],
          placeholder: 'name@example.com'
        }],
        type: Form.FieldTypes.TEXT_FIELD,
        gridWidth: 8
      }
    ],
    [
      {
        type: Form.FieldTypes.RENDERABLE,
        render: function() {
          return <FormLabel value='PASSWORD' />
        },
        gridWidth: 4
      },
      {
        inputs: [{
          name: 'password',
          displayName: 'password',
          validators: [
            Validators.notEmpty(),
            Validators.minLength(AuthConstants.MIN_PASSWORD_LENGTH),
            Validators.maxLength(AuthConstants.MAX_PASSWORD_LENGTH)
          ]
        }],
        type: Form.FieldTypes.PASSWORD,
        gridWidth: 8
      }
    ],
    [
      {
        type: Form.FieldTypes.RENDERABLE,
        render: function() {
          return <FormLabel value='CONFIRM PASSWORD' />
        },
        gridWidth: 4
      },
      {
        inputs: [{
          isDataField: false,
          name: 'confirm_password',
          displayName: 'confirm password',
          validators: [
            Validators.notEmpty(),
            Validators.minLength(AuthConstants.MIN_PASSWORD_LENGTH),
            Validators.maxLength(AuthConstants.MAX_PASSWORD_LENGTH),
            Validators.custom(
                input => input === self.refs.backboneForm.getInputValue('password'),
                'Passwords must match'
            )
          ]
        }],
        type: Form.FieldTypes.PASSWORD,
        gridWidth: 8
      }
    ],
    [{
      inputs: [{
        name: 'accept_tos',
        label: <span>I agree to the Sift Science <a href='/tos'>terms of service</a></span>
      }],
      type: Form.FieldTypes.CHECKBOX_GROUP,
      gridWidth: 8,
      gridOffset: 4
    }],
    [{
      inputs: [{
        value: 'Get started now',
        buttonProps: {
          size: SiftButton.Sizes.LARGE,
          priority: SiftButton.Priorities.PRIMARY
        }
      }],
      type: Form.FieldTypes.SUBMIT,
      gridWidth: 8,
      gridOffset: 4
    }],
    [{
      render: function() {
        return (
          <div className='new-or-existing'>
            Already have an account? <a href='/console/login'>Log in here.</a>
          </div>
        );
      },
      type: Form.FieldTypes.RENDERABLE,
      gridWidth: 8,
      gridOffset: 4
    }]
  ]}
/>

The above markup powers all of the interactions on our signup page – try playing around with it!

As you can see from the code snippet, a BackboneForm specifies the grid layout of the form as a two-dimensional layouts prop, the type of each form field that should be rendered, client-side validators, and callbacks on form submission success and error. Behind the scenes, the BackboneForm component also uses the ‘name’ property of an input to use as the JSON key it sends to the server for the input value. If an error response comes back and contains a hash of errors keyed by field name, BackboneForm will also display each server error below its relevant form field.

Wrapping up

We hope these tips can encourage and help those of you in similar situation. Our migration off of Rails and Marionette was a long and arduous process, but it has paid off immensely already as far as owning a codebase that we’re really proud of, speeding up development, and being able to share our experiences with the community. Please feel free to follow up with comments, questions, or challenges might be facing in your migration – we’d love to hear from you!

Author

2 Comments

  • Constantine Antonakos

    Are you subclassing other components in React? If so, how are you doing so?

    • We don’t subclass (using ES6 classes), but we do use composition. For example, in the BackboneForm component described above we maintain a ref to a more general Form component. The BackboneForm takes care of stitching together a Backbone model prop with the regular Form interactions of submitting and rendering form fields.