Links

Tutorial

Create a reactive Twitter clone in pure Ruby

In this step-by-step guide, I will show you how to create a Twitter clone in pure Ruby with Matestack, following the great screencasts from Chris McCord Phoenix LiveView Twitter Clone and Nate Hopkins Stimulus Reflex Twitter Clone. We will use the Gem matestack-ui-core, which enables us to implement our UI in some Ruby classes rather than writing ERB, HAML or Slim views. Furthermore we don't need to touch JavaScript in order to create reactive UI features, such as updating the DOM without a full browser page reload or syncing multiple web clients through Action Cable!
I've added a small demo showing you what you will be creating in this tutorial:
This guide utilizes the full power of Matestack and uses matestack-ui-core as a complete substitute for Rails views. If you only want to create UI components in pure Ruby on existing Rails views, please check out this guide
The code for this twitter clone tutorial is available in this repository.

Setup

  • Create a new Rails app and install some dependencies:
rails new twitter_clone --webpacker
cd twitter_clone
bundle add matestack-ui-core
yarn add matestack-ui-core
  • Use Rails scaffolder in order to setup some files:
rails g scaffold Post body:text likes_count:integer username:string

Model & Database

  • Modify generated migration in order to add defaults:
db/migrate/12345_create_posts.rb
class CreatePosts < ActiveRecord::Migration[6.0]
def change
create_table :posts do |t|
t.text :body
t.integer :likes_count, default: 0 # add default
t.string :username
t.timestamps
end
end
end
  • Migrate the database:
rails db:migrate
  • Add validations and default ordering to the Post model:
app/models/post.rb
class Post < ApplicationRecord
default_scope { order(created_at: :desc) }
validates :body, presence: true, allow_blank: false
validates :username, presence: true
end

Import Matestack's JavaScript

Previously, in version 1.5, Vue and Vuex were imported automatically. Now this must be done manually which is the webpacker way. You can import it in app/javascript/packs/application.js or in another pack if you need.
  • Modify the JavaScript pack in order to require matestack-ui-core and deactivate turbolinks:
app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
require("@rails/ujs").start()
// require("turbolinks").start() //remove
require("@rails/activestorage").start()
require("channels")
import Vue from 'vue/dist/vue.esm'
import Vuex from 'vuex'
import MatestackUiCore from 'matestack-ui-core'
let matestackUiApp = undefined
document.addEventListener('DOMContentLoaded', () => {
matestackUiApp = new Vue({
el: "#matestack-ui",
store: MatestackUiCore.store
})
})

Application Layout and Views

On app/views/layouts/application.html.erb do:
  • Add Bootstrap CSS via CDN (or any other CSS framework)
  • Remove turbolinks attributes (just for cleanup)
  • Add the matestack-ui ID to a DOM element within (not on!) the body tag
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>TwitterClone</title>
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<meta charset="utf-8">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_pack_tag 'application' %>
</head>
<body>
<div id="matestack-ui">
<%= yield %>
</div>
</body>
</html>
  • Delete all generated app/views/posts views - we don't need them!

Controller

  • Add Matestack's helper to your application controller:
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Matestack::Ui::Core::Helper
end
  • Remove not required code from the generated controller:
app/controllers/posts_controller.rb
class PostsController < ApplicationController
# GET /posts
# GET /posts.json
def index
@posts = Post.all
end
# POST /posts
# POST /posts.json
def create
@post = Post.new(post_params)
respond_to do |format|
if @post.save
format.html { redirect_to @post, notice: 'Post was successfully created.' }
format.json { render :show, status: :created, location: @post }
else
format.html { render :new }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
private
# Only allow a list of trusted parameters through.
def post_params
params.require(:post).permit(:username, :body)
end
end

Matestack App and Pages

  • Add a matestack folder and create a Matestack app and a Matestack Post index page file:
mkdir -p app/matestack/twitter_clone
touch app/matestack/twitter_clone/app.rb
mkdir -p app/matestack/twitter_clone/pages/posts
touch app/matestack/twitter_clone/pages/posts/index.rb
  • Add some basic code to the files:
app/matestack/twitter_clone/app.rb
class TwitterClone::App < Matestack::Ui::App
def response
div class: "container" do
heading size: 1, text: "Twitter Clone", class: "mb-5"
yield if block_given?
end
end
end
app/matestack/twitter_clone/pages/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
def prepare
@posts = Post.all
end
def response
@posts.each do |post|
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain post.username
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: post.body
end
end
end
end

Add Matestack to the Controller

  • Reference Matestack's app and page on the controller and index action:
app/controllers/posts_controller.rb
class PostsController < ApplicationController
matestack_app TwitterClone::App # add this
# GET /posts
# GET /posts.json
def index
# @posts = Post.all
render TwitterClone::Pages::Posts::Index # add this
end
# ...
end
Test the current state
  • Fire up the Rails server and see what we got at this point (heads up: we still need a little more code yet)
rails s
  • Navigate to localhost:3000/posts
You should see the heading "Twitte Clone" and that's it. We don't have any posts in our database, so we need a form to create one!

Add a Reactive Form

  • Add a reactive form to the index page
  • Use the form_config_helper method returning a config hash in order to inject a hash into the form without polluting the code
  • Refactor the code by using methods serving as 'partials'
app/matestack/twitter_clone/pages/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
def prepare
@posts = Post.all
end
def response
post_form_partial
post_list_partial
end
private
def post_form_partial
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 4, text: "New Post", class: "mb-3"
matestack_form form_config_helper do
div class: "mb-3" do
form_input key: :username, type: :text, placeholder: "Username", class: "form-control"
end
div class: "mb-3" do
form_textarea key: :body, placeholder: "What's up?", class: "form-control"
end
div class: "mb-3" do
button 'submit', type: :submit, class: "btn btn-primary", text: "Post!"
end
end
end
end
def form_config_helper
{
for: Post.new, path: posts_path, method: :post,
# optional: in order to map Bootstrap's CSS classes, you can adjust the form error rendering like so:
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
}
end
def post_list_partial
@posts.each do |post|
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain post.username
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: post.body
end
end
end
end
  • Slightly change the response within the create action in order to support the new form:
app/controllers/posts_controller.rb
class PostsController < ApplicationController
# ...
# POST /posts
# POST /posts.json
def create
@post = Post.new(post_params)
# respond_to do |format|
# if @post.save
# format.html { redirect_to @post, notice: 'Post was successfully created.' }
# format.json { render :show, status: :created, location: @post }
# else
# format.html { render :new }
# format.json { render json: @post.errors, status: :unprocessable_entity }
# end
# end
if @post.save
render json: {
message: 'Post was successfully created.'
}, status: :created
else
render json: {
errors: @post.errors,
message: 'Post could not be created.'
}, status: :unprocessable_entity
end
end
# ...
end
Test the current state
  • Navigate to localhost:3000/posts
You should see a basic index page with a form at the top. When submitting the form without any values, ActiveRecord errors should appear below the input fields without a browser page reload. When submitting valid data, the form should reset automatically without a browser page reload, but you will still have to reload the browser in order to see the new post!
To get that reactivity to work, we need make use of the async component.

Add Matestack's Async Component

  • Add success: {emit: "submitted"} to the form config
  • Wrap the post_list_partial with an async, configured to rerender when the event submitted is received
app/matestack/twitter_clone/pages/posts/index.rb
# ...
def form_config_helper
{
for: Post.new, path: posts_path, method: :post,
errors: {
wrapper: {tag: :div, class: 'invalid-feedback'},
input: {class: 'is-invalid'}
},
success: {emit: "submitted"}
}
end
def post_list_partial
async rerender_on: "submitted", id: "post-list" do
@posts.each do |post|
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain post.username
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: post.body
end
end
end
end
# ...
Test the current state
  • Navigate to localhost:3000/posts
Cool! Now you should see the list automatically updating itself after form submission without a browser page reload! And we didn't have to write any JavaScript. Just two lines of simple Ruby code! How cool is that?
Now we need to add some action components in order to "like" the posts.

Enable "likes"

  • Add the like route:
config/routes.rb
Rails.application.routes.draw do
resources :posts do
member do
put 'like', to: 'posts#like'
end
end
end
  • Add the like action on the controller:
app/controllers/posts_controller.rb
# ...
# PUT /posts/1/like
def like
@post = Post.find params[:id]
@post.increment(:likes_count)
if @post.save
render json: {
message: 'Post was successfully liked.'
}, status: :created
else
render json: {
errors: @post.errors,
message: 'Post could not be liked.'
}, status: :unprocessable_entity
end
end
# ...
  • Refactor the post_list_partial and use another method, defining the post itself
  • Add the like action component and emit a post-specific event
  • Wrap the post in an async, configured to rerender itself on the event emitted by the action component
app/matestack/twitter_clone/pages/index.rb
# ...
def post_list_partial
async rerender_on: "submitted", id: "post-list" do
@posts.each do |post|
post_partial(post)
end
end
end
def post_partial(post)
async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" do
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain post.username
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: post.body, class: "mb-5"
action path: like_post_path(post), method: :put, success: {emit: "liked_post_#{post.id}"} do
button class: "btn btn-light" do
plain "Like (#{post.likes_count})"
end
end
end
end
end
# ...
Test the current state
  • Navigate to localhost:3000/posts
When you click the "Like" button on a post, you will see the counter increasing without a full page reload! Again: Reactivity without any JavaScript!
Great! We added a reactive form and reactive actions. We can now add some reactive feedback on top using the toggle component!

Add Reactive Feedback Using the toggle Component

  • Add failure event submission to the form config like: failure: {emit: "form_failed"},
  • Add a toggle component in order to render the success message for 5 seconds
  • Add a toggle component in order to render the failure message for 5 seconds
app/matestack/twitter_clone/pages/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
def prepare
@posts = Post.all
end
def response
post_form_partial
post_list_partial
end
private
def post_form_partial
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 4, text: "New Post", class: "mb-3"
matestack_form form_config_helper do
# ...
end
end
toggle show_on: "submitted", hide_after: 5000 do
div class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top" do
heading size: 4, text: "Success: {{ event.data.message }}"
end
end
toggle show_on: "form_failed", hide_after: 5000 do
div class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top" do
heading size: 4, text: "Error: {{ event.data.message }}"
end
end
end
def form_config_helper
{
for: Post.new, path: posts_path, method: :post,
success: {emit: "submitted"},
failure: {emit: "form_failed"},
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
}
end
# ...
end
Test the current state
  • Navigate to localhost:3000/posts
Great! Now we get instant feedback after performing successful or unsuccessful form submissions! And still no line of JavaScript involved! The same approach would work for our actions, but we do not want to have that feedback after performing the actions in this example!
All of the above described reactivity only applies for one client. A second user wouldn't see the new post, unless he reloads his browser page. But of course, we want to sync all connected clients! It's time to integrate ActionCable!

Integrate Action Cable

  • Generate an ActionCabel channel
rails generate channel MatestackUiCoreChannel
  • Adjust the generated files like:
app/javascript/channels/matestack_ui_core_channel.js
import consumer from "./consumer"
import MatestackUiCore from 'matestack-ui-core'
consumer.subscriptions.create("MatestackUiCoreChannel", {
connected() {
// Called when the subscription is ready for use on the server
},
disconnected() {
// Called when the subscription has been terminated by the server
},
received(data) {
// Called when there's incoming data on the websocket for this channel
MatestackUiCore.eventHub.$emit(data.event, data)
}
});
app/channels/matestack_ui_core_channel.rb
class MatestackUiCoreChannel < ApplicationCable::Channel
def subscribed
stream_from "matestack_ui_core"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
  • Broadcast the cable__created_post event from the create action on the posts controller
  • Broadcast the cable__liked_post_xyz event from the like action on the posts controller
app/controllers/posts_controller.rb
# ...
# PUT /posts/1/like
def like
@post = Post.find params[:id]
@post.increment(:likes_count)
if @post.save
ActionCable.server.broadcast('matestack_ui_core', {
event: "cable__liked_post_#{@post.id}"
})
render json: {
message: 'Post was successfully liked.'
}, status: :created
else
render json: {
errors: @post.errors,
message: 'Post could not be liked.'
}, status: :unprocessable_entity
end
end
# POST /posts
def create
@post = Post.new(post_params)
if @post.save
ActionCable.server.broadcast('matestack_ui_core', {
event: 'cable__created_post'
})
render json: {
message: 'Post was successfully created.'
}, status: :created
else
render json: {
errors: @post.errors,
message: 'Post could not be created.'
}, status: :unprocessable_entity
end
end
# ...
  • Adjust the event name used for list rerendering
app/matestack/twitter_clone/pages/posts/index.rb
# ...
def form_config_helper
{
for: Post.new, path: posts_path, method: :post,
success: {emit: "submitted"},
failure: {emit: "form_failed"},
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
}
end
def post_list_partial
async rerender_on: "cable__created_post", id: "post-list" do
@posts.each do |post|
post_partial(post)
end
end
end
# ...
  • Add the adjusted event names to the per-post async config
  • Remove the success event emits from the actions - the event for per post-rerendering will be pushed from the server now
app/matestack/twitter_clone/pages/index.rb
# ...
def post_list_partial
@posts.each do |post|
post_partial(post)
end
end
def post_partial post
# async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" do
async rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" do
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain post.username
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: post.body, class: "mb-5"
# action path: like_post_path(post), method: :put, success: {emit: "liked_post_#{post.id}"} do
action path: like_post_path(post), method: :put do
button class: "btn btn-light" do
plain "Like (#{post.likes_count})"
end
end
end
end
end
# ...
Test the current state
  • Navigate to localhost:3000/posts on one browser tab
  • Navigate to localhost:3000/posts on another browser tab
  • Place the browser tabs side by side, so that you can see both browser tab contents
  • Use the form and the actions on one browser tab and see how the content on the other tab gets updated and vice versa
Wow! We just had to copy and paste a JavaScript snippet once in order to integrate ActionCable, broadcast an event from the controller action and without any more added complexity, we get synced clients, implemented in pure Ruby! Fantastic!
We will take a short break before adding the next cool reactivity feature and refactor a little bit! Matestack encourages you to create a readable and maintainable UI implemetation. Therefore we will move some complexity from the current index page to a self contained Matestack component!

Create a Matestack Component

  • Create a components folder within the matestack folder
mkdir -p app/matestack/components
touch app/matestack/components/post.rb
  • Move code from the index page to the new component
  • adjust references to the given post parameter to be called as a method of the context object (context.post.id)
app/matestack/components/post.rb
class Components::Post < Matestack::Ui::Component
requires :post
def response
# copied from the index page
async rerender_on: "cable__liked_post_#{context.post.id}", id: "post-#{context.post.id}" do
div class: "mb-3 p-3 rounded shadow-sm" do
heading size: 5 do
plain context.post.username
small text: context.post.created_at.strftime("%d.%m.%Y %H:%M")
end
paragraph text: context.post.body, class: "mb-5"
action path: like_post_path(context.post), method: :put do
button class: "btn btn-light" do
plain "Like (#{context.post.likes_count})"
end
end
end
end
end
end
  • Adjust the index page in order to use the new component
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
def prepare
@posts = Post.all
end
def response
post_form_partial
post_list_partial
end
private
# ...
def post_list_partial
async rerender_on: "submitted", id: "post-list" do
@posts.each do |post|
# post_partial(post)
Components::Post.(post: post)
end
end
end
# def post_partial post
# async rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" do
# div class: "mb-3 p-3 rounded shadow-sm" do
# heading size: 5 do
# plain post.username
# small text: post.created_at.strftime("%d.%m.%Y %H:%M")
# end
# paragraph text: post.body, class: "mb-5"
# action path: like_post_path(post), method: :put do
# button class: "btn btn-light" do
# plain "Like (#{post.likes_count})"
# end
# end
# end
# end
# end
end
Test the current state
  • Navigate to localhost:3000/posts
Everything should be the same! We just refactored some code in order to better manage complexity.

Component Registry

Components can be invoked as we have done above (Components::Post.(post: post)). But sometimes the namespace can get a little long and in the interest of keeping our code beautiful, we can register our components so we can call them like:
# ...
def post_list_partial
async rerender_on: "submitted", id: "post-list" do
@posts.each do |post|
# post_partial(post)
post_component post: post
end
end
end
# ...
Let's refactor and set up a component registry and register our component.
  • Create a component registry file
touch app/matestack/components/registry.rb
  • Register the new component
app/matestack/components/registry.rb
module Components::Registry
def post_component(post:)
Components::Post.(post: post)
end
end
  • Adjust the index page to include the Components::Registry
  • Adjust the index page in order to use the component in the new way
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
include Components::Registry
def prepare
@posts = Post.all
end
def response
post_form_partial
post_list_partial
end
private
# ...
def post_list_partial
async rerender_on: "submitted", id: "post-list" do
@posts.each do |post|
# post_partial(post)
post_component post: post
end
end
end
# ...
end
Test the current state again
  • Navigate to localhost:3000/posts
Everything should be the same after this small refactoring.

The Cable Component

Now we will cover the last topic of this guide:
As described before, the async rerenders it's whole body. The async wrapping the whole post list therefore rerenders ALL posts. If our list of posts grows, the performance of the rerendering will decrease. In a lot of usecases, this will not be an issue since the UI is not too big/too complex. So go ahead and use async everywhere you're not rerendering big or complex UIs and enjoy the simplicity of that rerendering approach!
But now imagine, your post list will be too big at some point. We should switch the reactivity approach to a more granular one. Let's use the cable component alongside our already added ActionCable introduction and reuse pretty much all written code!

Use the cable Component For List Rerendering

  • Use the cable instead of the async component
app/matestack/twitter_clone/posts/index.rb
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
def prepare
@posts = Post.all
end
def response
post_form_partial
post_list_partial
end
private
# ...
def post_list_partial
# async rerender_on: "submitted", id: "post-list" do
cable prepend_on: "cable__created_post", id: "post-list" do
@posts.each do |post|
post_component post: post
end
end
end
end
  • Adjust the ActionCable broadcast on the create action on the post controller
app/controllers/posts_controller.rb
# ...
# POST /posts
def create