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 outthis guide
The code for this twitter clone tutorial is available in this repository.
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.
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() //removerequire("@rails/activestorage").start()require("channels")import Vue from'vue/dist/vue.esm'import Vuex from'vuex'import MatestackUiCore from'matestack-ui-core'let matestackUiApp =undefineddocument.addEventListener('DOMContentLoaded', () => { matestackUiApp =newVue({ el:"#matestack-ui", store:MatestackUiCore.store })})
classPostsController<ApplicationController matestack_app TwitterClone::App# add this# GET /posts# GET /posts.jsondefindex# @posts = Post.all render TwitterClone::Pages::Posts::Index# add thisend# ...end
Test the current state
railss
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
app/matestack/twitter_clone/pages/posts/index.rb
classTwitterClone::Pages::Posts::Index<Matestack::Ui::Pagedefprepare @posts =Post.allenddefresponse post_form_partial post_list_partialendprivatedefpost_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!"endendendenddefform_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'}} }enddefpost_list_partial @posts.each do|post| div class: "mb-3 p-3 rounded shadow-sm"do heading size: 5do plain post.username small text: post.created_at.strftime("%d.%m.%Y %H:%M")end paragraph text: post.bodyendendendend
app/controllers/posts_controller.rb
classPostsController<ApplicationController# ...# POST /posts# POST /posts.jsondefcreate @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# endif @post.save render json: { message: 'Post was successfully created.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be created.' }, status: :unprocessable_entityendend# ...end
Test the current state
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.
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"
config/routes.rb
Rails.application.routes.draw do resources :posts do member do put 'like', to: 'posts#like'endendend
app/controllers/posts_controller.rb
# ...# PUT /posts/1/likedeflike @post =Post.find params[:id] @post.increment(:likes_count)if @post.save render json: { message: 'Post was successfully liked.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be liked.' }, status: :unprocessable_entityendend# ...
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!
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 channelMatestackUiCore.eventHub.$emit(data.event, data) }});
app/channels/matestack_ui_core_channel.rb
classMatestackUiCoreChannel<ApplicationCable::Channeldefsubscribed stream_from "matestack_ui_core"enddefunsubscribed# Any cleanup needed when channel is unsubscribedendend
app/controllers/posts_controller.rb
# ...# PUT /posts/1/likedeflike @post =Post.find params[:id] @post.increment(:likes_count)if @post.saveActionCable.server.broadcast('matestack_ui_core', { event: "cable__liked_post_#{@post.id}" }) render json: { message: 'Post was successfully liked.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be liked.' }, status: :unprocessable_entityendend# POST /postsdefcreate @post =Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', { event: 'cable__created_post' }) render json: { message: 'Post was successfully created.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be created.' }, status: :unprocessable_entityendend# ...
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!
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:
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!
# ...# POST /postsdefcreate @post =Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', { event: 'cable__created_post', data: post_component(post: @post) # add this line }) render json: { message: 'Post was successfully created.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be created.' }, status: :unprocessable_entityendend# ...
Test the current state
You probably don't realize any difference on the UI, but now ONLY the fresh post will be rendered on the server and pushed to the cable component mounted in the browser. The cable component is configured to prepend (put on top) everything pushed from the server on the cable__created_post event. This reactivity approach is now already much more scalable in a context of big/complex UI rerendering.
The cable component can prepend, append, update and delete elements within its body or replace its whole body with something pushed from the server. We want to use the update feature in order to rerender a specific post when liked:
# ...# PUT /posts/1/likedeflike @post =Post.find params[:id] @post.increment(:likes_count)if @post.saveActionCable.server.broadcast('matestack_ui_core', {# event: "cable__liked_post_#{@post.id}"# no id required in the event name, the cable component will figure out which post# should be updated using the root element ID of the pushed component event: "cable__liked_post",# change the event name data: post_component(post: @post) # add this line }) render json: { message: 'Post was successfully liked.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be liked.' }, status: :unprocessable_entityendend# POST /postsdefcreate @post =Post.new(post_params)if @post.saveActionCable.server.broadcast('matestack_ui_core', { event: 'cable__created_post', data: post_component(post: @post) }) render json: { message: 'Post was successfully created.' }, status: :createdelse render json: { errors: @post.errors, message: 'Post could not be created.' }, status: :unprocessable_entityendend# ...
Test the current state
Again: you probably don't realize any difference on the UI, but now ONLY the updated post will be rendered on the server and pushed to the cable component mounted in the browser.
The cable component is configured to update the component pushed from the server on the cable__liked_post event. The cable component then reads the ID of the root element of the pushed component, looks for that ID within it's body and updates this element with the pushed component.
Now, we're rerendering the list and its elements completely with the cable component. As described, this is an ALTERNATIVE approach to the introduced async component approach. The cable component requires a bit more implementation and brain power but makes our reactivity more scalable. Use the cable component wherever you think async would be too slow at some point!
Ok, let's lazy load the list of posts in order to speed up initial page load when reading the posts from the database and rendering them gets "too slow" at some point. Take a deep breath: We will use async and cable together now!
Relax, it's super simple:
Lazy Load the Post List With Async's defer Feature
That was easy, right? The async requested its content right after the page was loaded. We moved the ActiveRecord query out of the prepare method out of following reason: When rendering a Matestack page/component, the prepare method is always called. This means, the ActiveRecord query is performed on the initial page load although we don't need the data yet. Matestacks rendering mechanism stops rendering components which are wrapped in an async defer component on initial page load and only renders them, when they are explicitly requested in a subsequent HTTP call. Therefore we should take care of calling the ActiveRecord query only from within the deferred block. In our example we accomplish this by calling the helper method posts instead of using the instance variable @posts, formerly resolved in the prepare method.
Using this approach, it is super simple to speed up initial page loads without adding complexity or JavaScript to your code! Awesome!
Want some sugar? How about adding a CSS animation while lazy loading the post list?
// add this lineimport"./stylesheets/application.scss";
Speaking of fade effects: Let's add a second page in order to show, how you can use Matestacks app and transition component in order to implement dynamic page transitions without full browser page reload and without adding any JavaScript!
Implement Dynamic Page Transitions
We will create a profile page in order to save the username in a session cookie rather than asking for the username on the post form! Obviously, you would use proper user management via something like devise in a real world example!
Rails.application.routes.draw do resources :posts do member do put 'like', to: 'posts#like'endend scope :profile, as: :profile do get 'edit', to: 'profile#edit' put 'update', to: 'profile#update'endend
touchapp/controllers/profile_controller.rb
app/controllers/profile_controller.rb
classProfileController<ApplicationController matestack_app TwitterClone::App# GET /profile/editdefedit render TwitterClone::Pages::Profile::Editend# PUT /profile/updatedefupdateif profile_params[:username].blank? render json: { message: 'Profile could not be updated.', errors: {username: ["can't be blank!"]} }, status: :unprocessable_entityelse cookies[:username] = profile_params[:username] render json: { message: 'Profile was successfully updated.' }, status: :createdendendprivate# Only allow a list of trusted parameters through.defprofile_params params.require(:profile).permit(:username)endend
app/matestack/twitter_clone/app.rb
classTwitterClone::App<Matestack::Ui::Appdefresponse div class: "container"do# heading size: 1, text: "Twitter Clone", class: "mb-5"# yield if block_given? heading size: 1, text: "Twitter Clone" transition path: posts_path do button class: "btn btn-light", text: "Timeline"end transition path: profile_edit_path do button class: "btn btn-light", text: "Your Profile"end div class: "mt-5"doyieldifblock_given?end# add the toggle components here, this way all pages are able to trigger them! toggle show_on: "submitted", hide_after: 5000do div class: "container fixed-bottom w-100 bg-success text-white p-3 rounded-top"do heading size: 4, text: "Success: {{ event.data.message }}"endend toggle show_on: "form_failed", hide_after: 5000do div class: "container fixed-bottom w-100 bg-danger text-white p-3 rounded-top"do heading size: 4, text: "Error: {{ event.data.message }}"endendendendend
Test the current state
Great, we just added a second page and added some transition components to our app and without further effort, we implemented dynamic page transitions without touching any JavaScript. The transition component triggered the app to request the desired page at the server targeting the appropriate controller action through Rails routing and adjusted the DOM where we placed the yield if block_given? on our app!
We've built a reactive Twitter clone in pure Ruby. Fantastic! :)
Like it? Consider giving the project a star or even become a sponsor on Github, share it with your friends and colleagues (and family?) and join our Discord server! We're super happy about feedback, looking forward to hear your success stories and help you to build awesome things with Matestack!