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: