Realtime with React and Rails

Written by: Leigh Halliday

When I was thinking about creating something to showcase using ActionCable (websockets) in a Rails app with React, I first thought of building a chat. But everybody builds a chat. So next, I thought about a realtime dashboard. But then I realized I had no data in this demo app with all but one user (me). So I decided to build a realtime map application that allows you to broadcast your location to anyone you wish.

In this article, we'll explore how to use Rails, React (via react_on_rails gem), MobX, and websockets (via ActionCable). The demo app can be found here. The full repository can be found here.

Getting Started with a New Rails App

We'll start by generating a new Rails app using the command rails new tag_along --database=postgresql --webpack. Next we'll add the react_on_rails gem and run the following command after committing our changes: rails generate react_on_rails:install. Remember to add <%= javascript_pack_tag 'webpack-bundle' %> to your application layout file.

In a previous article, I was asked why I chose react_on_rails rather than just relying on the webpacker gem that ships in the latest version of Rails. I posed that question to Justin Gordon, the creator of react_on_rails, and he said that it also uses webpacker but provides view helpers and built-in support for server-rendered React components.

Creating Our Models

For this application, we'll be working with just two models: Trip, which keeps track of a single tracking session, and Checkin, which are all of the location updates and what time they occurred.

The trips migration looks like:

class CreateTrips < ActiveRecord::Migration[5.1]
  def change
    create_table :trips do |t|
      t.string :viewer_uuid, null: false
      t.string :owner_uuid, null: false
      t.string :name, null: false
      t.timestamps
    end
    add_index :trips, :viewer_uuid
    add_index :trips, :owner_uuid
  end
end

The Trip model looks like:

class Trip < ApplicationRecord
  has_many :checkins
  validates :viewer_uuid, :owner_uuid, :name, presence: true
  before_validation :set_uuids, on: :create
  def set_uuids
    self.viewer_uuid = SecureRandom.uuid
    self.owner_uuid = SecureRandom.uuid
  end
end

The checkins migration looks like:

class CreateCheckins < ActiveRecord::Migration[5.1]
  def change
    create_table :checkins do |t|
      t.integer :trip_id, null: false
      t.decimal :lat, null: false, precision: 10, scale: 6
      t.decimal :lon, null: false, precision: 10, scale: 6
      t.datetime :captured_at, null: false
      t.timestamps
    end
    add_index :checkins, :trip_id
  end
end

The Checkin model is pretty small:

class Checkin < ApplicationRecord
  belongs_to :trip
  validates :trip_id, :lat, :lon, :captured_at, presence: true
end

Organizing React

Now that we have our models and initial setup done, we'll work on organizing our React folder structure. What I've chosen to do is to create a new "bundle" called Trip in the client folder with the following subfolders:

  • components: which can be "injected" with MobX store

  • containers: where we wrap main entry-points with a MobX Provider

  • services: contains code for communicating with Rails API/sockets

  • startup: registering our containers with ReactOnRails

  • stores: MobX store will live here

Just remember to update the client/webpack.config.js file in the entry section to make it aware of our new bundle: './app/bundles/Trip/startup/registration'.

We'll start in the startup folder, where there's a single file called registration.jsx. The job of this file is to "register" any component we wish to render from within a Rails view.

// client/app/bundles/Trip/startup/registration.jsx
import ReactOnRails from 'react-on-rails';
import NewTripContainer from '../containers/NewTripContainer';
import ViewTripContainer from '../containers/ViewTripContainer';
ReactOnRails.register({
  NewTripContainer,
  ViewTripContainer
});

Setting Up MobX

MobX is a state-management tool for React. It's an alternative to using Redux and one that I find a lot simpler to set up and use. It has an "object-oriented" feel as opposed to the purely functional feel of Redux.

We need to install the mobx and mobx-react packages. In addition to these, we'll need to install babel-plugin-transform-decorators-legacy and follow that up with adding this plugin to our .babelrc file: "plugins": ["transform-decorators-legacy"]. While not necessary, using decorators with MobX makes things a lot cleaner and easier.

Inside of the stores folder, we'll create the TripStore, where we'll manage the state for the Trip and its Checkins. We declare which properties we'll track state changes of by adding them at the top of the class using the @observable decorator, which comes with MobX.

// client/app/bundles/Trip/stores/TripStore.js
import { observable, action } from 'mobx';
import TripApi from '../services/TripApi';
class TripStore {
  @observable trip = {};
  @observable checkins = [];
  constructor() {
    this.tripApi = new TripApi();
  }
}
const store = new TripStore();
export default store;

It's important to note how the store was exported at the bottom of the file: as an instance of the store rather than the class itself. We want a single instance of this store across the whole application.

We'll next take a look at the NewTripContainer component. It's not really a component, though, rather just wrapping our real component in a Provider component and also passing along the params that come from our Rails view. By wrapping Provider around our component, we can "inject" the MobX store into any of its children.

// client/app/bundles/Trip/containers/NewTripContainer.jsx
import React from 'react';
import { Provider } from 'mobx-react';
import TripStore from '../stores/TripStore';
import NewTrip from '../components/NewTrip';
export default (props, _railsContext) => {
  return (
    <Provider TripStore={TripStore}>
      <NewTrip {...props} />
    </Provider>
  );
};

If we look at the NewTrip component itself, not too much is going on. We are simply including two children, which handle most of the work.

import React from 'react';
import TripForm from './TripForm';
import TripMap from './TripMap';
export default class NewTrip extends React.Component {
  render() {
    return (
      <div>
        <TripForm />
        <TripMap />
      </div>
    )
  }
}

We render this in our views/trips/new view with the following helper provided by react_on_rails. The route and controller/action don't do anything too special, but you can check them out by visiting the repository provided in the introduction.

<%= react_component('NewTripContainer', props: {}, prerender: false) %>

!Sign up for a free Codeship Account

Posting to Rails

When the page loads, we'll ask the user to enter their name, which we then post to our Rails app. In Rails, we'll insert a new Trip into the database and respond with the model details as JSON. This part does not use websockets. If we start from the Rails perspective, our action looks like:

# app/controllers/trips_controller.rb
def create
  clean_old_trips
  trip = Trip.create!(trip_params)
  render json: trip.to_json
end

If you're wondering what clean_old_trips does, its job is to keep my free Heroku database small. Also, note that error-handling is fairly (entirely) absent from this demo. We're going to assume the client sends us beautifully valid data (which is a horrible assumption).

The TripForm component is below. This is the first component we will "inject" the TripStore into. What this means is that we will have a prop called TripStore, which allows us to call any action or access any of the observable properties we set up. Comments have been added inline below.

import React from 'react';
import { observer, inject } from 'mobx-react';
// Inject the TripStore into our component as a prop.
@inject('TripStore')
// Make our class "react" (re-render) to store changes.
@observer
export default class TripForm extends React.Component {
  // When user submits form, call the `createTrip` action, passing the name.
  handleSubmit = (e) => {
    e.preventDefault();
    const name = this.nameInput.value;
    this.props.TripStore.createTrip(name);
  }
  render() {
    const {TripStore} = this.props;
    // If we already have a trip in our store, display a link that can be
    // shared with anyone you want to share your realtime location with.
    if (TripStore.trip.name) {
      const trip_url = `${window.location.protocol}//${window.location.host}/trips/${TripStore.trip.viewer_uuid}`;
      return (
        <section className="trip-form-container">
          <p>
            Tracking <strong>{TripStore.trip.name}</strong>,
            share this link: <a href={trip_url}>{trip_url}</a>
          </p>
        </section>
      )
    }
    // Display the form allowing user to create a new Trip for themselves
    return (
      <section className="trip-form-container">
      </section>
    )
  }
}

Inside of our TripStore, we can add the action called above. It will use the API service we set up and, following successful creation, will subscribe to realtime updates (in this case, about our own location) and at the same time will start sending realtime location info to the server.

// client/app/bundles/Trip/stores/TripStore.js
@action createTrip = (name) => {
  this.tripApi.createTrip(name).
    then(trip => {
      // update our observable property, triggering re-render in component
      this.trip = trip;
      // subscribe to websocket channel for this specific "trip"
      this.tripApi.subscribeTrip(trip.viewer_uuid, checkin => {
        this.recordCheckin(checkin)
      });
      // send our location to server
      this.postCheckin();
    });
}

Inside of our API service, our POST to the Rails server looks like:

import ActionCable from 'actioncable';
export default class TripApi {
  constructor() {
    // for use later on in article when we talk about websockets
    this.cable = ActionCable.createConsumer('/cable');
    this.subscription = false;
  }
  createTrip = (name) => {
    return fetch('/trips', {
      method: 'post',
      headers: new Headers({
        'Content-Type': 'application/json'
      }),
      body: JSON.stringify({
        trip: {name}
      })
    }).
    then(response => response.json());
  }
}

ActionCable + Websockets

When working with ActionCable in Rails, I had to add the redis gem and update the config/cable.yml file to point to the correct Redis servers on both development and production:

development:
  adapter: redis
  url: redis://localhost:6379/1
  channel_prefix: tag_along_development
production:
  adapter: redis
  url: <%= ENV['REDIS_URL'] %>
  channel_prefix: tag_along_production

We are going to create a file called trip_channel.rb in the app/channels folder. Each Channel in ActionCable is meant to handle a single set of logic, similar to a Controller.

Let's talk terminology first:

  • Channel: :Like a Controller in ActionCable, handling communication for a single use-case.

  • Room: If you think of a chatroom, this defines which "room" you are interested in sending and receiving realtime communication with. In our case, it will be for a specific Trip.

  • Consumer: This is the client; in our case, the browser that will connect to the server. Consumers can both send and receive information over the websocket connection.

  • Subscribe: When the Consumer connects to the server for a specific Channel + Room.

  • Broadcast: Sending information to all subscribers of a specific Channel + Room.

class TripChannel < ApplicationCable::Channel
  # called when user first subscribes
  # we can define where their information is "broadcast" from
  def subscribed
    stream_from "trip_#{params[:room]}"
  end
  # called when a Consumer sends information to the server
  def receive(data)
    # find trip using owner_uuid
    trip = Trip.find_by!(owner_uuid: data['owner_uuid'])
    # add additional checkin
    # not recording in demo to keep DB small on free Heroku
    # checkin = trip.checkins.create!({
    #   lat: data['lat'],
    #   lon: data['lon'],
    #   captured_at: Time.zone.at(data['captured_at'] / 1000)
    # })
    # broadcast checkin to subscribers
    ActionCable.server.broadcast("trip_#{params[:room]}", {
      lat: data['lat'],
      lon: data['lon'],
      captured_at: data['captured_at']
    })
  end
end

Receiving Realtime Data

To be able to send data over websockets, we'll first need to connect to the socket and then subscribe to a Channel + Room. If you look back to where the TripApi service in JavaScript was introduced, there was a line in the constructor that looked like this.cable = ActionCable.createConsumer('/cable');. This sets us up with a connection to the server as a consumer of this websocket.

Next we'll look at the subscribeTrip function in our TripApi, which is called whenever we want to send/receive information for a Channel + Room. We provide a callback function that is called once every time the server sends realtime data to us as a Consumer.

// client/app/bundles/Trip/services/TripApi.js
subscribeTrip = (viewer_uuid, callback) => {
  this.subscription = this.cable.subscriptions.create({
    channel: "TripChannel",
    room: viewer_uuid
  }, {
    received: callback
  });
}

This function was called inside of the createTrip action function of the TripStore:

// client/app/bundles/Trip/stores/TripStore.js
this.tripApi.subscribeTrip(trip.viewer_uuid, checkin => {
  this.recordCheckin(checkin)
});

In our case, the only things the server will be sending to us are "checkin" details, which is why the arrow function receives a variable called checkin. It simply calls another action that pushes the checkin to an array.

// client/app/bundles/Trip/stores/TripStore.js
@action recordCheckin = (checkin) => {
  this.checkins.push({
    lat: parseFloat(checkin.lat),
    lon: parseFloat(checkin.lon),
    captured_at: parseInt(checkin.captured_at)
  });
  // Let's just keep last 25 checkins for performance
  this.checkins = this.checkins.slice(-25);
}

Sending Realtime Data

We talked about how realtime data was received, but how did it get to the server in the first place? After subscribing to the Channel + Room, we have a subscription object. What we will do is ask for the user's location every two seconds, sending that information to the server using this subscription.

// client/app/bundles/Trip/stores/TripStore.js
@action postCheckin = () => {
  // ask for location
  navigator.geolocation.getCurrentPosition(position => {
    // send location + owner_uuid (secret and only owner knows about it)
    this.tripApi.postCheckin(
      this.trip.owner_uuid,
      position.coords.latitude,
      position.coords.longitude,
      position.timestamp
    );
    // 2 seconds later do the whole thing again
    setTimeout(() => {
      this.postCheckin();
    }, 2000);
  });
}

What postCheckin does in the API is to send the information over websockets using the subscription and its send function.

// client/app/bundles/Trip/services/TripApi.js
postCheckin = (owner_uuid, lat, lon, captured_at) => {
  this.subscription.send({
    owner_uuid,
    lat,
    lon,
    captured_at
  });
}

Showing Checkin Locations on the Map

We've looked at how to both send data to the server via websockets and how to receive realtime updates back. But what are we going to do with this stream of location details? We'll show them on a map! For this, we are using the react-map-gl package, which works with MapBox.

// client/app/bundles/Trip/components/TripMap.jsx
import React from 'react';
import { observer, inject } from 'mobx-react';
import MapGL, {Marker} from 'react-map-gl';
import moment from 'moment';
import MARKER_STYLE from '../markerStyle';
// You must sign up for a free access token
const token = process.env.MapboxAccessToken;
@inject('TripStore')
@observer
export default class TripStore extends React.Component {
  constructor() {
    super();
    this.state = {
      viewport: {
        latitude: 43.6532,
        longitude: -79.3832,
        // other viewport properties like width, height, zoom
      },
      settings: {
        // settings
      }
    };
  }
  // render a Marker for each checkin location
  renderMarker = (checkin) => {
    return (
      <Marker key={checkin.captured_at} longitude={checkin.lon} latitude={checkin.lat} >
        <div className="station">
          <span>{moment(checkin.captured_at).format('MMMM Do YYYY, h:mm:ss a')}</span>
        </div>
      </Marker>
    );
  }
  // Callback sent to the Map to handle dragging / panning / zooming of map
  onViewportChange = (viewport) => {
    this.setState({viewport});
  }
  // Helper function to set the viewport to the user's last checkin location
  viewport = () => {
    const {TripStore} = this.props;
    let latitude = 43.6532;
    let longitude = -79.3832;
    if (TripStore.checkins.length > 0) {
      const last = TripStore.checkins[TripStore.checkins.length - 1];
      latitude = last.lat;
      longitude = last.lon;
    }
    return {
      ...this.state.viewport,
      latitude,
      longitude
    };
  }
  render() {
    const {TripStore} = this.props;
    const viewport = this.viewport();
    // render actual map, mapping over each `checkins` in the TripStore
    return (
      <MapGL
        {...viewport}
        {...this.state.settings}
        mapStyle="mapbox://styles/mapbox/dark-v9"
        onViewportChange={this.onViewportChange}
        mapboxApiAccessToken={token} >
        <style>{MARKER_STYLE}</style>
        { TripStore.checkins.map(this.renderMarker) }
      </MapGL>
    );
  }
}

Conclusion

Although it was a fair amount of code, we built something pretty darn cool: a realtime location-tracking map you can share with your friends and family! We used a lot of new technologies to make it happen, including React, MobX, ActionCable, and MapBox.

Using ActionCable in React is really no different from using it in standard JavaScript. The key is that once you make your websocket connection, you can then subscribe to a Channel + Room. From there, you are free to both send and receive information. How you decide to display that information is up to you!

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.