Building a Reddit-like Commenting System with Rails
“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.
Approach
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).
Models
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:
app/models/story.rb
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.
apps/models.comment.rb
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:
rails c
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 commentable_id
.
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.
Routes
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:
config/routes.rb
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:
rake routes
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.
Controllers
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.
app/controllers/stories_controller.rb
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.
app/controllers/comments_controller.rb
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.
Views
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.
app/views/stories/index.html.erb
<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.
app/views/stories/show.html.erb
<%= 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.
apps/views/comments/_comment.html.erb
<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.
rails s
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.
Good luck!
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.