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!