Building a Reddit-like Commenting System with Rails
This article will walk you through how to create threaded comments in rails using polymorphic associations. The tutorial was written by Mark Webster from the Shipyard Team. Mark and his co-founders are learning how to code through Codementor’s Featured Star program, and this tutorial is based on what he has learned so far.
“It takes a village.” It’s an appropriate way to describe learning to code. Without the wealth of online tutorials, blog posts, and StackOverflow answers, this journey would be exponentially more difficult. And the majority of that content has been shared for no other reason than a passion and enthusiasm for helping others.
While still very much a newbie myself, I feel I now have an opportunity to give back a bit and write my first tutorial. I recently implemented threaded commenting using polymorphic associations in my coding project, and share the details below in the spirit of reciprocal generosity.
As someone new to Rails, this comes with the disclaimer that there are many other ways to build this. The approach outlined here would likely need to be revisited for performance reasons at scale. You can also find gems that offer similar solutions. But it’s always good to build things from scratch when learning, so you have a good understanding of how things work. And the tutorial below is fairly straightforward, and most importantly, it works. Hopefully you will find it helpful.
Comments are an important part of building engaging communities online. They give users a chance to share their thoughts about a blog post, video, or piece of content. When comments are nested, or threaded, they also give users a chance to engage directly with one another, creating lively discussions and debates. Massive platforms like YouTube and reddit are driven by their threaded commenting features.
Let’s use reddit as the starting point for this tutorial. We’ll assume your project involves someone submitting a story link to share on the site. Other users can then comment directly on that story, or comment on another user’s comment.
To build this, you could also use one model, with one column for “
story_id” and one for “
comment_id” (meaning one column would always be nil). Or you could use polymorphic associations.
With a polymorphic association, we’ll create one Comments model. There will then be one column for “
commentable_id”, which will store the ID of the object we’re commenting on, and then “
commentable_type”, which will indicate what type of object we’re commenting on (in this case, a Story or a Comment).
For this tutorial, we’ll be starting with a brand new Rails app (using Rails 4). We’re only going to add what we need to demonstrate this feature, so for starters, let’s create the stories model. This is what users submit to our Reddit-like site, so it’s just a Title and URL (that the title would link out to).
rails g model story title:text url:string rake db:migrate
So now we have the objects that make the core of our user experience. We decided to use “
text” for the title, so we’re not limited by length (string is limited to 255 characters). Now let’s add the model for comments.
rails g model comment body:text commentable_id:integer commentable_type:string rake db:migrate
Now that we have these two models created, we need to create the associations between them. The easiest place to start is with Stories. A story can have many comments, so we need to add that. However, Rails would normally assume that comments would include a column called “
story_id” which it doesn’t so we need include the name we gave to the polymorphic association:
class Story < ActiveRecord::Base has_many :comments, as: :commentable end
Since a comment can also have many comments, we’re going to include the same thing in the Comments model. But before that, we need to let Rails know that Comments can belong to more than one model (stories or comments), so we need to specify that it belongs to a polymorphic association.
class Comment < ActiveRecord::Base belongs_to :commentable, polymorphic: true has_many :comments, as :commentable end
So to recap, a Story can have many comments. A Comment can have many comments. And since a comment can belong to more than one model, we specify that a comment belongs to a polymorphic association through commentable.
Now let’s see how this works. Fire up your trusty Rails console:
Since we have no entries in our database for our Reddit-like site, let’s create one:
Story.create(title: "Live 1:1 help from expert developers", url: "http://www.codementor.io")
You should then see this entry be created:
INSERT INTO "stories" ("title", "url", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["title", "Live 1:1 help from expert developers"], ["url", "http://www.codementor.io"], ["created_at", "2016-01-11 22:02:54.637379"], ["updated_at", "2016-01-11 22:02:54.637379"]]
We then want to add a comment to that story, to make sure our associations work properly. Let’s enter it through the comments association with the story, to mimic if we were to leave a comment on a story on the story’s show page. We can just use Story.first since it’s the only entry in our database.
Story.first.comments.create(body: "What a helpful resource!")
And we should see the following:
INSERT INTO "comments" ("body", "commentable_id", "commentable_type", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) [["body", "What a helpful resource"], ["commentable_id", 1], ["commentable_type", "Story"], ["created_at", "2016-01-11 22:08:03.701599"], ["updated_at", "2016-01-11 22:08:03.701599"]]
You’ll see that Rails knew that the
commentable_type was Story, since it was a comment on a story, and that it used the story_id (1, since this was our first entry) as the
Now let’s add a comment to that first comment, creating our first threaded/nested comment. We could do it the same way:
Comment.first.comments.create(body: "I agree, very helpful!")
And we should see the following:
INSERT INTO "comments" ("commentable_id", "commentable_type", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) [["commentable_id", 1], ["commentable_type", "Comment"], ["body", "I agree, very helpful"], ["created_at", "2016-01-11 22:33:35.018312"], ["updated_at", "2016-01-11 22:33:35.018312"]]
Success! Our models are setup correctly, and all the data flows the way it should.
As the traffic cop for Rails, we have to tell our routes.rb what to do with web requests. First we’ll tell the app to use the index page for stories as the root for this site. Then we’ll add the resources for stories and comments. This will create all of the default actions (index, show, new, edit, create, update and destroy) for each controller. But to each, we’ll also add a nested resource, to nest those actions for comments within each set of routes. So it looks like this:
Rails.application.routes.draw do root 'stories#index' resources :stories do resources :comments end resources :comments do resources :comments end end
Let’s see if everything is routing correctly. Since we didn’t limit the default routes, we’ll see more than we need (for instance, we won’t have a comments Index page), but this will help you see how it’s all nested. In the Terminal, check your routes with:
This is what you should see:
Prefix Verb URI Pattern Controller#Action root GET / stories#index story_comments GET /stories/:story_id/comments(.:format) comments#index POST /stories/:story_id/comments(.:format) comments#create new_story_comment GET /stories/:story_id/comments/new(.:format) comments#new edit_story_comment GET /stories/:story_id/comments/:id/edit(.:format) comments#edit story_comment GET /stories/:story_id/comments/:id(.:format) comments#show PATCH /stories/:story_id/comments/:id(.:format) comments#update PUT /stories/:story_id/comments/:id(.:format) comments#update DELETE /stories/:story_id/comments/:id(.:format) comments#destroy stories GET /stories(.:format) stories#index POST /stories(.:format) stories#create new_story GET /stories/new(.:format) stories#new edit_story GET /stories/:id/edit(.:format) stories#edit story GET /stories/:id(.:format) stories#show PATCH /stories/:id(.:format) stories#update PUT /stories/:id(.:format) stories#update DELETE /stories/:id(.:format) stories#destroy comment_comments GET /comments/:comment_id/comments(.:format) comments#index POST /comments/:comment_id/comments(.:format) comments#create new_comment_comment GET /comments/:comment_id/comments/new(.:format) comments#new edit_comment_comment GET /comments/:comment_id/comments/:id/edit(.:format) comments#edit comment_comment GET /comments/:comment_id/comments/:id(.:format) comments#show PATCH /comments/:comment_id/comments/:id(.:format) comments#update PUT /comments/:comment_id/comments/:id(.:format) comments#update DELETE /comments/:comment_id/comments/:id(.:format) comments#destroy comments GET /comments(.:format) comments#index POST /comments(.:format) comments#create new_comment GET /comments/new(.:format) comments#new edit_comment GET /comments/:id/edit(.:format) comments#edit comment GET /comments/:id(.:format) comments#show PATCH /comments/:id(.:format) comments#update PUT /comments/:id(.:format) comments#update DELETE /comments/:id(.:format) comments#destroy
Success again! You’ll see there are stories, there are comments, then there are comments nested within stories, and comments nested within comments. Now that the requests are going where they need to go, let’s make sure we’re providing the proper response for each.
As we’re dealing with stories and comments, we’ll need to create a controller for each. We’ll start with stories. For the sake of brevity in this tutorial, we’re going to skip creating, editing, or deleting stories, since we have at least one to already work with. So we’ll just handle the index and show requests, so that we can view the story we created. Notice that we don’t have to address comments at all here.
class StoriesController < ApplicationController def index @stories = Story.all end def show @story = Story.find(params[:id]) end end
Now we move onto the comments controller. Here we don’t need index and show, as comments always live on story view pages, and don’t have their own pages. But we’ll need new and create, because we want to create new comments. We also need to create a method to let Rails know if we’re creating a comment for a story or for a comment.
class CommentsController < ApplicationController before_action :find_commentable def new @comment = Comment.new end def create @comment = @commentable.comments.new comment_params if @comment.save redirect_to :back, notice: 'Your comment was successfully posted!' else redirect_to :back, notice: "Your comment wasn't posted!" end end private def comment_params params.require(:comment).permit(:body) end def find_commentable @commentable = Comment.find_by_id(params[:comment_id]) if params[:comment_id] @commentable = Story.find_by_id(params[:story_id]) if params[:story_id] end end
Since our comments are nested within other comments or stories, we’re using the instance variable
@commentable in the create action. We have a private method (
find_commentable) that is telling Rails that if the params contains a
comment_id, it’s a comment on a comment, and if it has story_id, it’s a comment on a story. We then added a filter at the top of the controller, telling Rails to run the private method before performing any other action (otherwise it wouldn’t know what
@commentable was when it got to the create action).
Lastly, we need to create the views to see all this magic we created work.
So there are four elements we need for all of this to work. We need a show page for a story, and index page for stories, a way to see comments, and a way to post comments.
Let’s start with the index page and show page. The index page will display all of the stories in our database, along with a link to the show page for each story.
<h1>Stories</h1> <% @stories.each do |story| %> <p> <%= link_to(story.title, story.url, target: "_blank") %> - <%= link_to("Show Page", story) %> </p> <% end %>
Now we need the show page for each story. Inspired by a show page on reddit, this page will have details about the story, a form for submitting a comment about the story, the display of each comment, and the ability to comment on a comment.
<%= link_to(@story.title, @story.url, target: "_blank") %><br/> <small>Submitted <%= time_ago_in_words(@story.created_at) %> ago</small> <h3>Comments</h3> <%= form_for [@story, Comment.new] do |f| %> <%= f.text_area :body, placeholder: "Add a comment" %><br/> <%= f.submit "Add Comment" %> <% end %> <ul> <%= render(partial: 'comments/comment', collection: @story.comments) %> </ul>
You’ll see that we broke out some elements into a partial. So we need to add that view file.
<li> <%= comment.body %> - <small>Submitted <%= time_ago_in_words(comment.created_at) %> ago</small> <%= form_for [comment, Comment.new] do |f| %> <%= f.text_area :body, placeholder: "Add a Reply" %><br/> <%= f.submit "Reply" %> <% end %> <ul> <%= render partial: 'comments/comment', collection: comment.comments %> </ul> </li>
Because we’re passing this partial the collection of comments, it displays each comment and the form for replying to that comment. But then it renders itself within itself (recursive!), to display the replies each comment might have. And by using the ul/li structure, we’re making sure they all nest correctly when they display. Fancy.
See Our Handiwork in Action
Fire up the server, and take a look at what we did.
Because we created a story, a comment, and a reply already, so you should see them all. Then play around with adding a new comment on that story, and then a reply to that comment.
If you add the new and create actions and new view to Stories, you will basically have the core functionality of reddit built. Not bad for a quick tutorial.
If you want to see the github repo for this project, it’s posted here.
Missed our earlier posts? Catch-up on our journey to finally learning to code with our previous posts:
- Being Technical isn’t “Binary”: Shipyard Cofounders’ Coding Journey
- Startup Co-founders’ Coding Journey: From Newbie to Informed
- Non-Technical Startup Cofounders Learn How to Build Simple Web App from Scratch
- Learning to Code with a Mentor is All About the 1:1 Interaction
- 3 Things to Consider When Building Your First App
About the Author
This article was written by Mark Webster from ShipyardNYC. Mark and his two other co-founders, Vipin and Minesh, are participating in Codementor’s Featured Stars Program, where they will work with a dedicated mentor to bolster their understanding of programming concepts and become better entrepreneurs.