Full-stack Ruby: build a realtime web app with React.rb and Opal

Opal is a transpiler that converts Ruby code into browser-friendly JavaScript, opening the door for developers to build frontend web applications with Ruby. It’s a particularly intriguing option for backend Ruby developers who want to use the same language across their entire stack.

I recently gave Opal a try myself, incorporating it into a simple realtime todo list demo that I built with RethinkDB and Ruby. My demo is a full-stack Ruby application, with Sinatra on the backend and an Opal-based library called React.rb on the frontend. React.rb wraps Facebook’s popular React framework, adapting it to support idiomatic Ruby conventions. Developers can build React components in native Ruby, using a domain-specific language (DSL) to describe the generated HTML markup.

After a fair amount of tinkering, I succeeded in making my demo application work as expected. Although I found that both Opal and React.rb are still somewhat experimental projects that need further development before they are ready for serious use in production environments, they offer some fascinating possibilities.

Build the backend with RethinkDB and Sinatra

RethinkDB 2.0, released earlier this year, introduced EventMachine integration in the Ruby client driver. Developers can use EventMachine to perform RethinkDB queries in the background, which is ideal for consuming changefeeds. My demo application uses a changefeed to track updates on the todo list and broadcast them to clients via WebSocket:

EM.next_tick do
  conn = r.connect()
  r.table("todo").changes.em_run(conn) do |err, change|
    @clients.each {|c| c.send change.to_json }
  end
end

The backend also uses WebSockets to accept commands from the frontend. The following code tracks WebSocket client connections and handles the incoming commands, adding and updating todo list records as needed:

def query rql
  rql.run(conn = r.connect()).to_json
ensure
  conn.close
end

def setup_websocket ws
  ws.on(:close) { @clients.delete ws }
  ws.on(:open) { @clients << ws }

  ws.on :message do |msg|
    data = JSON.parse msg.data
    case data["command"]
    when "add"
      query r.table("todo").insert text: data["text"], status: false
    when "update"
      query r.table("todo").get(data["id"]).update status: data["status"]
    when "delete"
      query r.table("todo").get(data["id"]).delete()
    end
  end
end

The main Sinatra route handler checks to see if the incoming request is a WebSocket connection or a regular HTTP request. It will respond accordingly, conveniently making it possible to handle both on the same port:

get "/" do
  if Faye::WebSocket.websocket? request.env
    ws = Faye::WebSocket.new request.env
    setup_websocket ws
    ws.rack_response
  else
    haml :index
  end
end

I also use Sinatra to add conventional REST endpoints as needed. For example, I make it possible to retrieve current todo list items with a simple GET request:

get "/api/items" do
  query r.table("todo").coerce_to("array")
end

In my config.ru file, I used Opal’s built-in Sprockets integration to configure an asset pipeline. This makes it possible for Sinatra to dynamically serve transpiled assets in response to requests:

Faye::WebSocket.load_adapter("thin")

react_path = ::React::Source.bundled_path_for("react-with-addons.js")

$opal = Opal::Server.new do |s|
  s.append_path File.dirname react_path
  s.append_path "client"
  s.main = "main"
end

map "/assets" do
  run $opal.sprockets
end

$opalinit = Opal::Processor.load_asset_code($opal.sprockets, 'main')

I also take the opportunity to add React.rb into the mix and configure the Faye WebSockets library to use Thin, the underlying web server that runs the application. I chose Thin because it plays nicely with EventMachine, which I need for my background changefeed.

The global $opalinit variable contains a string of JavaScript code that the page must execute to bootstrap the Opal environment. All you have to do is put its contents into a script tag in the HAML template:

!!!
%html
  %head
    %title Test
    %script(src="/assets/react-with-addons.js")
    %script(src="/assets/main.js")
    %script= $opalinit
  %body

Build the frontend with React.rb and Opal

Developers who have previous experience with React can expect a relatively easy transition working with React.rb. It’s largely a wrapper, but it does a nice job of adapting React’s underlying concepts so that you can express all of the same functionality in Ruby.

React applications consist of components, which the developer composes to build their frontend. Inside of the component, there’s a render function that generates the associated HTML markup. Components can also emit signals that are handled by parent components. In general, React developers try to minimize the amount of stateful data managed by individual components.

To create a component with React.rb, you define a class and include the React::Component mixin. Inside of the component’s render method, you can use the React.rb templating DSL to build your markup. The following component enables the user to input a new todo list item:

class TodoAdd
  include React::Component

  def render
    div do
      input(type: "text", placeholder: "Input task name", ref: "text")
      button {"Add"}.on(:click) do
        text = self.refs[:text].dom_node.value
        self.emit :add, text: text
      end
    end
  end
end

It includes a text entry field and a submission button. When the user clicks the button, the application will execute the on(:click) block, which emits a signal with the contents of the entry box. I also made a component that displays all of the todo list items, emitting a signal when the user toggles one of the checkboxes:

class TodoList
  include React::Component

  def render
    ul do
      params[:items].each do |item|
        li do
          label do
            input(type: "checkbox", checked: item["status"]).on(:click) do |e|
              self.emit :toggle, id: item["id"], status: e.current_target.checked
            end
            span { item["text"] }
          end
        end
      end
    end
  end
end

The todo list component doesn’t actually store the todo list items itself, it attaches to an items parameter that is provided by the parent component in which it is instantiated. Here’s the top-level component that sets up the application:

class App
  include React::Component

  define_state(:items) { [] }
  after_mount :setup

  def setup
    Browser::HTTP.get("/api/items").then do |res|
      self.items = res.json
      setup_websocket
    end
  end

  def setup_websocket
    @ws = Browser::Socket.new()

    @ws.on(:open) { p "Connection opened" }
    @ws.on(:close) { p "Socket closed" }

    # ... handle WebSocket messages here
  end

  def transmit data
    @ws.puts data.to_json
  end

  def render
    div do
      present(TodoList, items: self.items).on :toggle do |data|
        transmit command: "update", id: data["id"], status: data["status"]
      end

      present(TodoAdd).on :add do |data|
        transmit command: "add", text: data["text"]
      end
    end
  end
end

$document.ready do
  React.render(React.create_element(App), `document.body`)
end

It creates the items property, which it initially populates by fetching data from the API endpoint defined in Sinatra. In the render method, it displays the TodoList component and the TodoAdd component. When the user adds or toggles a todo list item, the signal handlers will send the appropriate command to the backend via WebSocket.

The Browser::Socket class, which I use to create the WebSocket client connection, comes from a library called [opal-browser][]. In addition to WebSocket support, the library provides Ruby-friendly wrappers around many different standard browser APIs.

With all of that plumbing in place, all the application needs is an on(:message) handler to perform the necessary changes to the todo list when the application receives live updates from the backend changefeed via WebSocket:

@ws.on(:message) do |e|
  data = JSON.parse e.data
  puts "Received:", data

  # Add new item
  if data[:new_val] && !data[:old_val]
    self.items = self.items << data[:new_val]
  # Update existing item
  elsif data[:new_val] && data[:old_val]
    self.items = self.items.map do |i|
      i["id"] == data[:new_val]["id"] ? data[:new_val] : i
    end
  # Remove deleted item
  elsif !data[:new_val] && data[:old_val]
    self.items = self.items - [data[:old_val]]
  end
end

Caveats and next steps

Although I’m enthusiastic about Opal’s long-term potential, I did face some challenges while I was attempting to build the demo. Parts of the Opal project, particularly the associated libraries like opal-browser, are fragile and under-documented. My initial attempts at using the Opal project’s Vienna framework instead of React.rb were largely unsuccessful.

I also faced some difficulty with debugging while working with Opal. Unlike CoffeeScript, Opal doesn’t really produce a clean 1:1 translation of the code. In order to provide some of the capabilities and behaviors that you would expect to find in a real Ruby environment, it uses a fair amount of runtime code. As a result, the tracebacks and error messages are often very unhelpful. There is, however, some support for source maps, which can take some of the pain out of debugging.

All of those caveats aside, building a demo app that uses Ruby on both the frontend and the backend was an exhilarating experience. Transpilers play an increasingly foundational role in web application development. As projects like WebAssembly gain momentum and make JavaScript a better target for transpilers, I think we can expect to see projects like Opal become better and more practical for day-to-day use. It might not take long for this kind of full-stack Ruby web development to evolve from an esoteric curiosity to a real-world option.

To get started with RethinkDB today, visit our ten-minute guide. To learn more about Opal, visit the Opal website.

Resources: