Clapton
Clapton is a Ruby on Rails gem for building web apps with pure Ruby only (no JavaScript and no HTML templates).
Stack
- Ruby on Rails
- Action Cable (WebSocket)
- Ruby2JS (for compiling Ruby to JavaScript)
- Morphdom
- importmap
Installation
Add this line to your application's Gemfile:
gem 'clapton'
And then execute:
$ bundle install
Usage
To use a Clapton component in your view:
# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
def render
div = c(:div)
@state.tasks.each do |task|
div.add(TaskItemComponent.new(id: task[:id], title: task[:title], due: task[:due], done: task[:done]))
end
btn = c(:button)
btn.add(c(:text, "Add Task"))
btn.add_action(:click, :TaskListState, :add_task)
div.add(btn)
end
end
# app/components/task_item_component.rb
class TaskItemComponent < Clapton::Component
def render
div = c(:div)
btn = c(:button)
btn.add(c(:text, @state.done ? "✅" : "🟩"))
btn.add_action(:click, :TaskListState, :toggle_done)
tf = c(:input, @state, :title)
tf.add_action(:input, :TaskListState, :update_title)
dt = c(:datetime, @state, :due)
dt.add_action(:input, :TaskListState, :update_due)
div.add(btn).add(tf).add(dt)
end
end
# app/states/task_list_state.rb
class TaskListState < Clapton::State
attribute :tasks
def add_task(params)
task = Task.create(title: "New Task", due: Date.today, done: false)
self.tasks << { id: task.id, title: task.title, due: task.due, done: task.done }
end
def toggle_done(params)
task = Task.find(params[:id])
task.update(done: !params[:done])
self.tasks.find { |t| t[:id] == params[:id] }[:done] = task.done
end
def update_title(params)
task = Task.find(params[:id])
task.update(title: params[:title])
self.tasks.find { |t| t[:id] == params[:id] }[:title] = task.title
end
def update_due(params)
task = Task.find(params[:id])
task.update(due: params[:due])
self.tasks.find { |t| t[:id] == params[:id] }[:due] = task.due
end
end
# app/states/task_item_state.rb
class TaskItemState < Clapton::State
attribute :id
attribute :title
attribute :due
attribute :done
end
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.all
@components = [
[:TaskListComponent, { tasks: @tasks.map { |task| { id: task.id, title: task.title, due: task.due, done: task.done } } }]
]
end
end
# app/views/layouts/application.html.erb
<%= clapton_javascript_tag %>
# app/views/tasks/index.html.erb
<%= clapton_tag %>
Make sure to include the necessary route in your config/routes.rb
:
mount Clapton::Engine => "/clapton"
Component rendering
<%= clapton_component_tag(
:TaskListComponent,
{
tasks: @tasks.map { |task| { id: task.id, title: task.title, due: task.due, done: task.done } }
}
) %>
Generate Component and State
rails generate clapton TaskList
After running the generator, you will see the following files:
app/components/task_list_component.rb
app/states/task_list_state.rb
Special Event
render
The render
event is a special event that is triggered when the component is rendered.
# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
def render
# ...
div = c(:div)
div.add_action(:render, :TaskListState, :add_empty_task, debounce: 500)
end
end
Effect
The effect
method is a method that is triggered when the state is changed.
# app/components/task_list_component.rb
class TaskListComponent < Clapton::Component
effect [:tasks] do |state|
puts state[:tasks]
end
end
If dependencies are not specified, the effect will be triggered on the first render.
# app/components/video_player_component.rb
class VideoPlayerComponent < Clapton::Component
effect [] do
puts "First render"
end
end
Streaming
Clapton supports streaming.
# app/states/chat_state.rb
class ChatState < Clapton::State
attribute :messages
def send(params)
self.messages << { role: "user", content: params[:content] }
yield continue: true # Continue the streaming
client = OpenAI::Client.new(
access_token: ENV.fetch("OPENAI_ACCESS_TOKEN"),
log_errors: true
)
self.messages << { role: "assistant", content: "" }
client.chat(
parameters: {
model: "gpt-4o-mini",
messages: messages,
stream: proc do |chunk, _bytesize|
if chunk.dig("choices", 0, "finish_reason") == "stop"
yield continue: false # Stop the streaming
end
self.messages.last[:content] << chunk.dig("choices", 0, "delta", "content")
yield continue: true
end
}
)
end
end
Optional
Action Cable
Clapton uses Action Cable to broadcast state changes to the client.
If you want to identify the user, you can set the current_user
in the connection.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if verified_user = User.find_by(id: cookies.signed[:user_id])
verified_user
else
reject_unauthorized_connection
end
end
end
end
Using with importmap-rails
Use clapton_javascript_tag
instead of javascript_importmap_tags
.
- <%= javascript_importmap_tags %>
+ <%= clapton_javascript_tag %>
Events
clapton:render
The clapton:render
event is a custom event that is triggered when the component is rendered.
document.addEventListener("clapton:render", () => {
console.log("clapton:render");
});
Testing
RSpec
# spec/spec_helper.rb
require "clapton/test_helper/rspec"
RSpec.configure do |config|
config.include Clapton::TestHelper::RSpec, type: :component
end
# spec/components/task_list_component_spec.rb
describe "TaskListComponent", type: :component do
it "renders" do
render_component("TaskListComponent", tasks: [{ id: 1, title: "Task 1", done: false, due: Time.current }])
# You can use Capybara matchers here
expect(page).to have_selector("input[type='text']")
end
end
Minitest
# test/test_helper.rb
require "clapton/test_helper/minitest"
class ActiveSupport::TestCase
include Clapton::TestHelper::Minitest
end
# test/components/task_list_component_test.rb
class TaskListComponentTest < ActiveSupport::TestCase
test "renders" do
render_component("TaskListComponent", tasks: [{ id: 1, title: "Task 1", done: false, due: Time.current }])
# You can use Capybara matchers here
assert_select "input[type='text']"
end
end
Deployment
Run bundle exec rake clapton:compile
to compile the components.
app/components
is codes that are compiled to JavaScript.
So, you need to ignore the directory from autoloading.
# config/application.rb
Rails.autoloaders.main.ignore(Rails.root.join("app/components"))
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run bin/dev
to start the development server.
Testing
Run bundle exec rake test
to run the test suite.
Run cd test/dummy && bundle exec rake test
to run the test suite for the dummy app.
Run cd test/dummy && bundle exec rspec
to run the test suite for the dummy app with RSpec.
Run cd lib/clapton/javascripts && npm run test
to run the test suite for the JavaScript part.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/kawakamimoeki/clapton. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.