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,533