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:
1
rails new twitter_clone --webpacker
2
cd twitter_clone
3
bundle add matestack-ui-core
4
yarn add matestack-ui-core
Copied!
    Use Rails scaffolder in order to setup some files:
1
rails g scaffold Post body:text likes_count:integer username:string
Copied!

Model & Database

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

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
1
// This file is automatically compiled by Webpack, along with any other files
2
// present in this directory. You're encouraged to place your actual application logic in
3
// a relevant structure within app/javascript and only use these pack files to reference
4
// that code so it'll be compiled.
5
6
require("@rails/ujs").start()
7
// require("turbolinks").start() //remove
8
require("@rails/activestorage").start()
9
require("channels")
10
11
import Vue from 'vue/dist/vue.esm'
12
import Vuex from 'vuex'
13
14
import MatestackUiCore from 'matestack-ui-core'
15
16
let matestackUiApp = undefined
17
18
document.addEventListener('DOMContentLoaded', () => {
19
matestackUiApp = new Vue({
20
el: "#matestack-ui",
21
store: MatestackUiCore.store
22
})
23
})
Copied!

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
1
<!DOCTYPE html>
2
<html>
3
<head>
4
<title>TwitterClone</title>
5
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
6
<%= csrf_meta_tags %>
7
<%= csp_meta_tag %>
8
9
<meta charset="utf-8">
10
11
<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">
12
13
<%= stylesheet_link_tag 'application', media: 'all' %>
14
<%= javascript_pack_tag 'application' %>
15
</head>
16
17
<body>
18
<div id="matestack-ui">
19
<%= yield %>
20
</div>
21
</body>
22
</html>
Copied!
    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
1
class ApplicationController < ActionController::Base
2
3
include Matestack::Ui::Core::Helper
4
5
end
Copied!
    Remove not required code from the generated controller:
app/controllers/posts_controller.rb
1
class PostsController < ApplicationController
2
3
# GET /posts
4
# GET /posts.json
5
def index
6
@posts = Post.all
7
end
8
9
# POST /posts
10
# POST /posts.json
11
def create
12
@post = Post.new(post_params)
13
14
respond_to do |format|
15
if @post.save
16
format.html { redirect_to @post, notice: 'Post was successfully created.' }
17
format.json { render :show, status: :created, location: @post }
18
else
19
format.html { render :new }
20
format.json { render json: @post.errors, status: :unprocessable_entity }
21
end
22
end
23
end
24
25
private
26
27
# Only allow a list of trusted parameters through.
28
def post_params
29
params.require(:post).permit(:username, :body)
30
end
31
32
end
Copied!

Matestack App and Pages

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

Add Matestack to the Controller

    Reference Matestack's app and page on the controller and index action:
app/controllers/posts_controller.rb
1
class PostsController < ApplicationController
2
3
matestack_app TwitterClone::App # add this
4
5
# GET /posts
6
# GET /posts.json
7
def index
8
# @posts = Post.all
9
render TwitterClone::Pages::Posts::Index # add this
10
end
11
12
# ...
13
end
Copied!
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)
1
rails s
Copied!
    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
1
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
2
3
def prepare
4
@posts = Post.all
5
end
6
7
def response
8
post_form_partial
9
post_list_partial
10
end
11
12
private
13
14
def post_form_partial
15
div class: "mb-3 p-3 rounded shadow-sm" do
16
heading size: 4, text: "New Post", class: "mb-3"
17
matestack_form form_config_helper do
18
div class: "mb-3" do
19
form_input key: :username, type: :text, placeholder: "Username", class: "form-control"
20
end
21
div class: "mb-3" do
22
form_textarea key: :body, placeholder: "What's up?", class: "form-control"
23
end
24
div class: "mb-3" do
25
button 'submit', type: :submit, class: "btn btn-primary", text: "Post!"
26
end
27
end
28
end
29
end
30
31
def form_config_helper
32
{
33
for: Post.new, path: posts_path, method: :post,
34
# optional: in order to map Bootstrap's CSS classes, you can adjust the form error rendering like so:
35
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
36
}
37
end
38
39
def post_list_partial
40
@posts.each do |post|
41
div class: "mb-3 p-3 rounded shadow-sm" do
42
heading size: 5 do
43
plain post.username
44
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
45
end
46
paragraph text: post.body
47
end
48
end
49
end
50
51
end
Copied!
    Slightly change the response within the create action in order to support the new form:
app/controllers/posts_controller.rb
1
class PostsController < ApplicationController
2
3
# ...
4
5
# POST /posts
6
# POST /posts.json
7
def create
8
@post = Post.new(post_params)
9
10
# respond_to do |format|
11
# if @post.save
12
# format.html { redirect_to @post, notice: 'Post was successfully created.' }
13
# format.json { render :show, status: :created, location: @post }
14
# else
15
# format.html { render :new }
16
# format.json { render json: @post.errors, status: :unprocessable_entity }
17
# end
18
# end
19
20
if @post.save
21
render json: {
22
message: 'Post was successfully created.'
23
}, status: :created
24
else
25
render json: {
26
errors: @post.errors,
27
message: 'Post could not be created.'
28
}, status: :unprocessable_entity
29
end
30
end
31
32
# ...
33
34
end
Copied!
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
1
# ...
2
3
def form_config_helper
4
{
5
for: Post.new, path: posts_path, method: :post,
6
errors: {
7
wrapper: {tag: :div, class: 'invalid-feedback'},
8
input: {class: 'is-invalid'}
9
},
10
success: {emit: "submitted"}
11
}
12
end
13
14
def post_list_partial
15
async rerender_on: "submitted", id: "post-list" do
16
@posts.each do |post|
17
div class: "mb-3 p-3 rounded shadow-sm" do
18
heading size: 5 do
19
plain post.username
20
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
21
end
22
paragraph text: post.body
23
end
24
end
25
end
26
end
27
28
# ...
Copied!
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
1
Rails.application.routes.draw do
2
resources :posts do
3
member do
4
put 'like', to: 'posts#like'
5
end
6
end
7
end
Copied!
    Add the like action on the controller:
app/controllers/posts_controller.rb
1
# ...
2
3
# PUT /posts/1/like
4
def like
5
@post = Post.find params[:id]
6
@post.increment(:likes_count)
7
8
if @post.save
9
render json: {
10
message: 'Post was successfully liked.'
11
}, status: :created
12
else
13
render json: {
14
errors: @post.errors,
15
message: 'Post could not be liked.'
16
}, status: :unprocessable_entity
17
end
18
end
19
20
# ...
Copied!
    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
1
# ...
2
3
def post_list_partial
4
async rerender_on: "submitted", id: "post-list" do
5
@posts.each do |post|
6
post_partial(post)
7
end
8
end
9
end
10
11
def post_partial(post)
12
async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" do
13
div class: "mb-3 p-3 rounded shadow-sm" do
14
heading size: 5 do
15
plain post.username
16
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
17
end
18
paragraph text: post.body, class: "mb-5"
19
action path: like_post_path(post), method: :put, success: {emit: "liked_post_#{post.id}"} do
20
button class: "btn btn-light" do
21
plain "Like (#{post.likes_count})"
22
end
23
end
24
end
25
end
26
end
27
28
# ...
Copied!
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
1
class TwitterClone::Pages::Posts::Index < Matestack::Ui::Page
2
3
def prepare
4
@posts = Post.all
5
end
6
7
def response
8
post_form_partial
9
post_list_partial
10
end
11
12
private
13
14
def post_form_partial
15
div class: "mb-3 p-3 rounded shadow-sm" do
16
heading size: 4, text: "New Post", class: "mb-3"
17
matestack_form form_config_helper do
18
# ...
19
end
20
end
21
toggle show_on: "submitted", hide_after: 5000 do
22
div class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top" do
23
heading size: 4, text: "Success: {{ event.data.message }}"
24
end
25
end
26
toggle show_on: "form_failed", hide_after: 5000 do
27
div class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top" do
28
heading size: 4, text: "Error: {{ event.data.message }}"
29
end
30
end
31
end
32
33
def form_config_helper
34
{
35
for: Post.new, path: posts_path, method: :post,
36
success: {emit: "submitted"},
37
failure: {emit: "form_failed"},
38
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
39
}
40
end
41
42
# ...
43
44
end
Copied!
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
1
rails generate channel MatestackUiCoreChannel
Copied!
    Adjust the generated files like:
app/javascript/channels/matestack_ui_core_channel.js
1
import consumer from "./consumer"
2
import MatestackUiCore from 'matestack-ui-core'
3
4
consumer.subscriptions.create("MatestackUiCoreChannel", {
5
connected() {
6
// Called when the subscription is ready for use on the server
7
},
8
9
disconnected() {
10
// Called when the subscription has been terminated by the server
11
},
12
13
received(data) {
14
// Called when there's incoming data on the websocket for this channel
15
MatestackUiCore.eventHub.$emit(data.event, data)
16
}
17
});
Copied!
app/channels/matestack_ui_core_channel.rb
1
class MatestackUiCoreChannel < ApplicationCable::Channel
2
def subscribed
3
stream_from "matestack_ui_core"
4
end
5
6
def unsubscribed
7
# Any cleanup needed when channel is unsubscribed
8
end
9
end
Copied!
    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
1
# ...
2
3
# PUT /posts/1/like
4
def like
5
@post = Post.find params[:id]
6
@post.increment(:likes_count)
7
8
if @post.save
9
ActionCable.server.broadcast('matestack_ui_core', {
10
event: "cable__liked_post_#{@post.id}"
11
})
12
render json: {
13
message: 'Post was successfully liked.'
14
}, status: :created
15
else
16
render json: {
17
errors: @post.errors,
18
message: 'Post could not be liked.'
19
}, status: :unprocessable_entity
20
end
21
end
22
23
# POST /posts
24
def create
25
@post = Post.new(post_params)
26
27
if @post.save
28
ActionCable.server.broadcast('matestack_ui_core', {
29
event: 'cable__created_post'
30
})
31
render json: {
32
message: 'Post was successfully created.'
33
}, status: :created
34
else
35
render json: {
36
errors: @post.errors,
37
message: 'Post could not be created.'
38
}, status: :unprocessable_entity
39
end
40
end
41
42
# ...
Copied!
    Adjust the event name used for list rerendering
app/matestack/twitter_clone/pages/posts/index.rb
1
# ...
2
3
def form_config_helper
4
{
5
for: Post.new, path: posts_path, method: :post,
6
success: {emit: "submitted"},
7
failure: {emit: "form_failed"},
8
errors: {wrapper: {tag: :div, class: 'invalid-feedback'}, input: {class: 'is-invalid'}}
9
}
10
end
11
12
def post_list_partial
13
async rerender_on: "cable__created_post", id: "post-list" do
14
@posts.each do |post|
15
post_partial(post)
16
end
17
end
18
end
19
20
# ...
Copied!
    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
1
# ...
2
3
def post_list_partial
4
@posts.each do |post|
5
post_partial(post)
6
end
7
end
8
9
def post_partial post
10
# async rerender_on: "liked_post_#{post.id}", id: "post-#{post.id}" do
11
async rerender_on: "cable__liked_post_#{post.id}", id: "post-#{post.id}" do
12
div class: "mb-3 p-3 rounded shadow-sm" do
13
heading size: 5 do
14
plain post.username
15
small text: post.created_at.strftime("%d.%m.%Y %H:%M")
16
end
17
paragraph text: post.body, class: "mb-5"
18
# action path: like_post_path(post), method: :put, success: {emit: "liked_post_#{post.id}"} do
19
action path: like_post_path(post), method: :put do
20
button class: "btn btn-light" do
21
plain "Like (#{post.likes_count})"
22
end
23
end
24
end
25
end
26
end
27
28
# ...
Copied!
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