React.rb

Build your next Rails app in 100% Ruby

Mitch VanDuyn & Loïc Boutet

You want to use React, but still have your beloved Rails environment ?

Our solution: React.rb

Use React

Write Ruby

Inside of Rails

A Quick React Overview

A React Page or App

is divided into components which are

 

small

testable

potentially reusable

organized into a tree like hierarchy

TopBar
User can type a todo, todo is added when user hits return key.
TodoList
Shows all todos based

on current filter

TodoItem
Displays Todo title and a checkbox. 

- When checked the todo is "complete".

- When the title is double clicked the Title is editable.

- A delete button will permanently delete the todo

Footer
shows number of items left, 
allows the filter to be changed
and all the completed components to be deleted

So we will end up with the following component hierarchy

Todos

|- TopBar

|- TodoList

|   |- TodoItem

|   |- TodoItem

|   |- ...

|- Footer


class Todos < React::Component::Base

  def render
    TopBar(...)
    TodoList(...)
    Footer(...)
  end

end

class TodoList < React::Component::Base

  def render
    TodoItem(...)
    TodoItem(...)
    ...
  end

end

where each component ends up as a class...

Each Component...

receives parameters

has state - which are reactive instance variables

defines a render method that generates its html

and has optional life cycle call backs

A component is "mounted" and sent its initial params

The  before_mount call back initializes any state, and instance variables

The component's render method is called which generates html based on the params, state, and instance variables

The component may be sent new parameters

Event handlers or timers update the state variables 

Lets add React to our Rails App

Its easy with the reactive-rails-generator

gem 'reactive_rails_generator', group: :development

add the gem to your Gemfile

$ bundle exec rails g reactrb:install --all
      insert  app/assets/javascripts/application.js
      insert  app/assets/javascripts/application.js
       route  mount ReactiveRecord::Engine => "/rr"
      create  app/views/components/.keep
      create  app/models/public/.keep
      create  app/views/components.rb
      create  app/models/_react_public_models.rb
     gemfile  reactive-ruby
     gemfile  react-rails (~> 1.3.0)
     gemfile  opal-rails (>= 0.8.1)
     gemfile  therubyracer
     gemfile  react-router-rails (~>0.13.3)
     gemfile  reactive-router
     gemfile  reactive-record
$ bundle update
Bundle updated!

bundle install

run the generator

 

  options

  --reactive-router to install reactive-router 

  --reactive-record to install reactive-record 

  --opal-jquery to install opal-jquery

  --all to do all the above

  Its recommend to install --all.

  You can easily remove things later!

 

$ bundle install

make sure to run bundle update

Good to Go!

For our first component lets work on the "Footer"

Lets start by writing a test spec:

# spec/components/footer_spec.rb
require 'spec_helper'

describe 'Footer', :js => true do

  it 'has the correct links' do
    mount "Todos::Footer", incomplete_count: 9, scope: :all
    page.should have_link("", href: "/todos")
    page.should have_link("", href: "/todos?scope=complete")
    page.should have_link("", href: "/todos?scope=active")
    page.save_screenshot "./spec/screen_shots/footer.png"
  end

  (0..2).each do |count|
    it "displays the pluralized incomplete count for #{count}" do
      mount "Todos::Footer", incomplete_count: count
      page.should have_content("#{count} #{'item'.pluralize(count)}")
    end
  end

  [:all, :complete, :active].each do |scope|
    it "highlights the #{scope} link" do
      mount "Todos::Footer", scope: scope
      page.should have_selector('a.selected', text: /#{scope}/i)
    end
  end

end

Our component will have 2 params: 

- incomplete_count (an integer) and

- scope (a string)

We will name our component Footer, and place it in the Todos module (i.e. Todos::Footer)

$ bundle exec rspec spec/components/footer_spec.rb
      create  app/views/components/todos/footer.rb
$ bundle exec rescue rspec spec/components/footer_spec.rb:5
Finished in 37.61 seconds (files took 7.37 seconds to load)
7 examples, 7 failures

Failed examples:

rspec ./spec/components/footer_spec.rb:5 # Footer has the correct links
rspec ./spec/components/footer_spec.rb[1:2] # Footer displays the pluralized incomplete count for 0
rspec ./spec/components/footer_spec.rb[1:3] # Footer displays the pluralized incomplete count for 1
rspec ./spec/components/footer_spec.rb[1:4] # Footer displays the pluralized incomplete count for 2
rspec ./spec/components/footer_spec.rb[1:5] # Footer highlights the all link
rspec ./spec/components/footer_spec.rb[1:6] # Footer highlights the complete link
rspec ./spec/components/footer_spec.rb[1:7] # Footer highlights the active link

Lets try it out...

No surprises... we will use the generator to add a component:

$ bundle exec rails g reactrb:component todos::footer

Lets just run the first test, and use rescue to stop the test so we can see what our component is doing so far:

Run options: include {:locations=>{"./spec/components/footer_spec.rb"=>[5]}}

...

RSpec::Expectations::ExpectationNotMetError: expected #has_link?("", {:href=>"/todos"}) to return true, got false
module Components
  module Todos
    class Footer < React::Component::Base

      # param :my_param
      # param param_with_default: "default value"
      # param :param_with_default2, default: "default value" # alternative syntax
      # param :param_with_type, type: Hash
      # param :array_of_hashes, type: [Hash]
      # collect_all_other_params_as :attributes  #collects all other params into a hash

      # The following are the most common lifecycle call backs,
      # the following are the most common lifecycle call backs# delete any that you are not using.
      # call backs may also reference an instance method i.e. before_mount :my_method

      before_mount do
        # any initialization particularly of state variables goes here.
        # this will execute on server (prerendering) and client.
      end

      after_mount do
        # any client only post rendering initialization goes here.
        # i.e. start timers, HTTP requests, and low level jquery operations etc.
      end

      before_update do
        # called whenever a component will be re-rerendered
      end

      before_unmount do
        # cleanup any thing (i.e. timers) before component is destroyed
      end

      def render
        div do
          "todos::footer"
        end
      end
    end
  end
end

Here is the blank component that was generated

module Components
  module Todos
    class Footer < React::Component::Base

      param :scope, type: String
      param :incomplete_count, type: Integer

      def selected_if(link)
        "#{'selected' if params.scope == link}"
      end

      def render
        footer(class: "footer") do
          span(class: "todo-count") do
            "#{params.incomplete_count} item#{'s' unless params.incomplete_count == 1} left"
          end
          ul(class: "filters") do
            li { a(class: selected_if("all"), href: "/todos") { "All" }}
            li { a(class: selected_if("complete"), href: "/todos?scope=complete") { "Completed" }}
            li { a(class: selected_if("active"), href: "/todos?scope=active") { "Active" }}
          end
        end
      end
    end
  end
end

Lets get it working per spec

module Components
  module Todos
    class Footer < React::Component::Base

      param :scope, type: String
      param :incomplete_count, type: Integer

      def render
        div do
          "todos::footer"
        end
      end
    end
  end
end

We need two params:

"scope" and "incomplete_todos".

 

Note that params may be optionally typed.  If 

the wrong type is passed a warning will be shown on JS console

module Components
  module Todos
    class Footer < React::Component::Base

      param :scope, type: String
      param :incomplete_count, type: Integer

      def render
        footer do
          span do
            "#{params.incomplete_count} item#{'s' unless params.incomplete_count == 1} left"
          end
          ul do
            li { a(href: "/todos") { "All" }}
            li { a(href: "/todos?scope=complete") { "Completed" }}
            li { a(href: "/todos?scope=active") { "Active" }}
          end
        end
      end
    end
  end
end

Now fill in our basic render method:

Children as specified in the tag's block,

and attributes (like href) are given in the tag

parameters.

At this point our component should pass the spec, but lets add styling and classes while we are at it

module Components
  module Todos
    class Footer < React::Component::Base

      param :scope, type: String
      param :incomplete_count, type: Integer

      def selected_if(link)
        "#{'selected' if params.scope == link}"
      end

      def render
        footer do
          span do
            "#{params.incomplete_count} item#{'s' unless params.incomplete_count == 1} left"
          end
          ul do
            li { a(class: selected_if("all"), href: "/todos") { "All" }}
            li { a(class: selected_if("complete"), href: "/todos?scope=complete") { "Completed" }}
            li { a(class: selected_if("active"), href: "/todos?scope=active") { "Active" }}
          end
        end
      end
    end
  end
end

We need to change the link style depending on which scope is passed, so we write a little helper method

....and use it to conditionally add the "selected" class to each link.

module Components
  module Todos
    class Footer < React::Component::Base

      param :scope, type: String
      param :incomplete_count, type: Integer

      def selected_if(link)
        "#{'selected' if params.scope == link}"
      end

      def render
        footer.footer do
          span.todo_count do
            "#{params.incomplete_count} item#{'s' unless params.incomplete_count == 1} left"
          end
          ul.filters do
            li { a(class: selected_if("all"), href: "/todos") { "All" }}
            li { a(class: selected_if("complete"), href: "/todos?scope=complete") { "Completed" }}
            li { a(class: selected_if("active"), href: "/todos?scope=active") { "Active" }}
          end
        end
      end
    end
  end
end

Finally we can use "HAML" style notation to specify classes.   Lets do that and clean things up a bit more.

$ bundle exec rspec spec/components/footer_spec.rb

Footer
  has the correct links
  displays the pluralized incomplete count for 0
  displays the pluralized incomplete count for 1
  displays the pluralized incomplete count for 2
  highlights the all link
  highlights the complete link
  highlights the active link

Finished in 18.01 seconds (files took 5.56 seconds to load)
7 examples, 0 failures

Now that we have a working component...

Lets use it in our app

# views/todos/index.html.erb
<section class="todoapp">
  <header class="header">
...
  </header>
  <section class="main" style="display: block;">
...
  </section>
  <footer class="footer" style="display: block;">
    <span class="todo-count"><%= pluralize(@uncomplete_todo.count, 'item')%> left</span>
    <ul class="filters">
      <li>
        <a href="/todos" class=<%= "selected" if @scope == "all" %>>All</a>
      </li>
      <li>
        <a href="/todos?scope=active" class=<%= "selected" if @scope == "active" %>>Active</a>
      </li>
      <li>
        <a href="/todos?scope=complete" class=<%= "selected" if @scope == "complete" %>>Completed</a>
      </li>
    </ul>
    <button class="clear-completed" style="display: none;"></button>
  </footer>
</section>
# views/todos/index.html.erb
<section class="todoapp">
  <header class="header">
...
  </header>
  <section class="main" style="display: block;">
...
  </section>
  <%= react_component "Todos::Footer", 
                      incomplete_count: @uncomplete_todo.count, 
                      scope: @scope %>
</section>

So far so good... Lets make a reactive component

TodoItem
Displays Todo title and a checkbox. 

- When checked the todo is "complete".

- When the title is double clicked the Title is editable.

- A delete button will permanently delete the todo

#spec/components/todo_item_spec.rb
require 'spec_helper'

describe 'TodoItem', :js => true do

  before(:each) do
    @todo = FactoryGirl.create(:todo)
    mount "Todos::TodoItem", todo: @todo
  end

  it 'displays the title' do
    page.should have_content(@todo.title)
  end

  it 'can mark the todo as completed' do
    find("[type=checkbox]").click
    sleep(2)
    @todo.reload.complete.should be_truthy
  end

  it 'can delete the todo' do
    find(".destroy").click
    sleep(2)
    expect(Todo.all).to be_empty
  end

  it 'can change the todo title' do
    find("label").double_click
    input = find(".edit")
    input.set("new capybara title")
    input.native.send_keys(:return)
    page.should have_content("new capybara title")
    sleep(2)
    @todo.reload.title.should eq("new capybara title")
  end

end

Notice how our component will take one parameter:  an ActiveRecord Todo 

We know what to do next...

      create  app/views/components/todos/todo_item.rb
$ bundle exec rails g reactrb:component todos::todo_item

Lets put our component together:

module Components
  module Todos
    class TodoItem < React::Component::Base

      param :todo, type: Todo
      define_state editing: false

      def item_classes
        "todo-item #{params.todo.complete ? "completed" : ""} #{state.editing ? "editing" : ""}"
      end

      def render
        li(class: item_classes) do
          state.editing ? edit_mode : display_mode
        end
      end
    end
  end
end

The "editing" state will determine which mode our component is in.

And we will define two methods to generate the html depending on the state...

Reactive-Record

module Components
  module Todos
    class TodoItem < React::Component::Base

      param :todo, type: Todo
      define_state editing: false

      def item_classes
        "todo-item #{params.todo.complete ? "completed" : ""} #{state.editing ? "editing" : ""}"
      end

      def render
        li(class: item_classes) do
          state.editing ? edit_mode : display_mode
        end
      end
    end
  end
end

Components can access your ActiveRecord models.

 

To enable this move the model rb file from "app/models" to "app/models/public".

 

The model class, attributes and scopes will now be accessible from components.

 

To secure your models you add access controls to each model

 

TodoItem#display_mode

# components/todo_item/display_mode.rb
module Components
  module Todos
    class TodoItem < React::Component::Base

      def display_mode
        div.view do
          input.toggle(
              type: :checkbox, 
              (params.todo.complete ? :defaultChecked : :unchecked) => true).
          on(:click) do
            params.todo.complete = !params.todo.complete
            params.todo.save
          end
          label { params.todo.title }.
          on(:doubleClick) do
            state.editing! true
          end
          a.destroy.on(:click) do
            params.todo.destroy
          end
        end
      end
      
    end
  end
end

Things to notice:

 

- attributes work just like ActiveRecord

- updating, saving & destroying models

- the on method attaches handlers

- state is updated using the ! method

 

when a component accesses state React.rb will remember, and update the component if that state changes.

 

ActiveRecord models are also "reactive" just like state variables

TodoItem#edit_mode

Life cycle:

 

- edit_mode is initialized to false

- when a user double clicks it is set to true

- this causes a re-render of the TodoItem

- when user hits enter the todo is saved

- and editing is set back to false

# components/todo_item/edit_mode.rb
module Components
  module Todos
    class TodoItem < React::Component::Base

      def edit_mode
        input.edit(value: params.todo.title).
        on(:blur) do
          state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            state.editing! false
          end
        end
      end

    end
  end
end
# components/todo_item/edit_mode.rb
module Components
  module Todos
    class TodoItem < React::Component::Base

      after_update do
        if state.editing
          edit_element = Element[".edit"]
          edit_element.focus
          `#{edit_element}[0].setSelectionRange(edit_element.val().length, edit_element.val().length)`
        end
      end

      def edit_mode
        input.edit(value: params.todo.title).
        on(:blur) do
          state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            state.editing! false
          end
        end
      end

    end
  end
end

Adding a callback

For the component to work very nicely we would like the text to be selected when the user begins editing. 

 

To do this we add an after_update callback.  

 

We will have to use some low level JS to do this, but that is easy in Opal-Ruby.  Opal also provides the Element class which is a basic wrapper on jQuery.

 

We use this to find our element, set the focus, and select the text.

Lets Test...

$ bundle exec rspec spec/components/todo_item_spec.rb -f d

TodoItem
  displays the title
  can mark the todo as completed
  can delete the todo
  can change the todo title

Finished in 23.11 seconds (files took 7.56 seconds to load)
4 examples, 0 failures


Lets build the TodoList component

write a test spec:

require 'spec_helper'

describe 'TodoItem', :js => true do

  it 'renders a TodoItem for each todo' do
    5.times { FactoryGirl.create(:todo) }

    mount "Todos::TodoList", todos: Todo.all

    Todo.all.each do |todo|
      page.should have_content(todo.title)
    end
  end

end
module Components
  module Todos
    class TodoList < React::Component::Base

      param :todos, type: [Todo]

      def render
        ul.todo_list do
          params.todos.each do |todo|
            TodoItem todo: todo
          end
        end
      end

    end
  end
end

and the component:

A reusable component

Consider the "TopBar" component...

 

Its requirements are to allow the user to type in some text, and either click to enter a todo, or leave to cancel.

 

That is very similar to the "edit_mode" method in the TodoItem class.

 

Lets turn the edit_mode method into a our first reusable component

module Components
  module Todos
    class TodoItem < React::Component::Base

      after_update do
        if state.editing
          edit_element = Element[".edit"]
          edit_element.focus
          `#{edit_element}[0].setSelectionRange(edit_element.val().length, edit_element.val().length)`
        end
      end

      def edit_mode
        input.edit(value: params.todo.title).
        on(:blur) do
          state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            state.editing! false
          end
        end
      end

    end
  end
end

We can move the after_update call back to TodoItem

module Components
  module Todos
    class TodoItem < React::Component::Base

      def edit_mode
        input.edit(value: params.todo.title).
        on(:blur) do
          state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            state.editing! false
          end
        end
      end

    end
  end
end

Change the class, and change edit_mode to render

module Components
  module Todos
    class EditTitle < React::Component::Base

      def render
        input.edit(value: params.todo.title).
        on(:blur) do
          state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            state.editing! false
          end
        end
      end

    end
  end
end

We will need three params:

- the todo,

- a call back for notifying the parent onCancel

- a call back for notifying the parent onSave

module Components
  module Todos
    class EditTitle < React::Component::Base

      param :todo, type: Todo
      # prefixing the name with _on will allow 
      # the proc to be used by the ".on" method
      param :_onCancel, type: Proc, allow_nil: true
      param :_onSave, type: Proc, allow_nil: true

      def render
        input.edit(value: params.todo.title).
        on(:blur) do
          params._onCancel #state.editing! false
        end.on(:change) do |e|
          params.todo.title = e.target.value
        end.on(:key_down) do |e|
          if e.key_code == 13
            params.todo.save
            params._onSave #state.editing! false
          end
        end
      end

    end
  end
end

Update the original TodoItem

module Components
  module Todos
    class TodoItem < React::Component::Base

      param :todo, type: Todo
      define_state editing: false

      def item_classes
        "todo-item #{params.todo.complete ? "completed" : ""} #{state.editing ? "editing" : ""}"
      end

      def render
        li(class: item_classes) do
          state.editing ? edit_mode : display_mode
        end
      end
    end
  end
end
module Components
  module Todos
    class TodoItem < React::Component::Base

      param :todo, type: Todo
      define_state editing: false

      def item_classes
        "todo-item #{params.todo.complete ? "completed" : ""} #{state.editing ? "editing" : ""}"
      end

      after_update do
        if state.editing
          edit_element = Element[".edit"]
          edit_element.focus
          `#{edit_element}[0].setSelectionRange(edit_element.val().length, edit_element.val().length)`
        end
      end

      def render
        li(class: item_classes) do
          if state.editing
            EditTitle(todo: todo).
            on(:save) { state.editing! false }.
            on(:cancel) { state.editing! false}
          else
            display_mode
          end
        end
      end
    end
  end
end

And retest TodoItem

$ bundle exec rspec spec/components/todo_item_spec.rb -f d

TodoItem
  displays the title
  can mark the todo as completed
  can delete the todo
  can change the todo title

Finished in 23.11 seconds (files took 7.56 seconds to load)
4 examples, 0 failures


We will skip writing a EditTitle test for now...

Our last component!

<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <h2 class="new_todo"><%= link_to 'New Todo', new_todo_path %></h2>
    <!-- <input class="new-todo" placeholder="What needs to be done?" autofocus=""> -->
  </header>
  <section class="main" style="display: block;">
    <!-- <input class="toggle-all" type="checkbox"> -->
    <label for="toggle-all">Mark all as complete</label>
    <ul class="todo-list">
      <% @todos.each do |todo| %>
        <li class="<%= "completed" if todo.complete? %>">
          <div class="view">
            <%= link_to edit_todo_url(todo) do %>
              <input class="toggle" type="checkbox" <%= "checked" if todo.complete? %> onclick="return  true">
              <label><%= todo.title %></label>
            <% end %>
            <%= link_to '', todo, class: :destroy, method: :delete, data: { confirm: 'Are you sure?' } %>
          </div>
        </li>
      <% end %>
    </ul>
  </section>
  <footer class="footer" style="display: block;">
    <span class="todo-count"><%= pluralize(@uncomplete_todo.count, 'item')%> left</span>
    <ul class="filters">
      <li>
        <a href="/todos" class=<%= "selected" if @scope == "all" %>>All</a>
      </li>
      <li>
        <a href="/todos?scope=active" class=<%= "selected" if @scope == "active" %>>Active</a>
      </li>
      <li>
        <a href="/todos?scope=complete" class=<%= "selected" if @scope == "complete" %>>Completed</a>
      </li>
    </ul>
    <button class="clear-completed" style="display: none;"></button>
  </footer>
</section>

Its going to replace index.html so lets start with that

module Components
  module Todos
    class Index < React::Component::Base

      export_state :scope

      before_mount do
        state.new_todo! Todo.new(complete: false)
        Index.scope! :all
      end

      def render
        section.todoapp do
          header.header do
            h1 {"todos"}
            EditTitle(todo: state.new_todo).
            on(:save) { state.new_todo! Todo.new(complete: false) }
          end
          section.main(style: {display: :block}) do
            ul.todo_list do
              Todo.send(Index.scope).each do |todo|
                TodoItem todo: todo
              end
            end
          end
          Footer(scope: Index.scope, incomplete_count: Todo.active.count)
        end
      end
    end
  end
end

Credits

  • Opal - Ruby Tranpiler Team
  • Facebook React
  • @zetachang for the original DSL idea
  • @ajjahn for getting all the testing done
  • @fkchang for his good ideas
  • @loicboutet for pulling this demo together

More Info

  • visit http://reactrb.org
  • https://gitter.im/zetachang/react.rb live chat
  • stack overflow tag react.rb for specific questions
  • contributors WELCOME!

reactrb-intro

By Mitch VanDuyn

reactrb-intro

Learn how to add React.rb to your existing rails app

  • 1,452