โ† Back to Course

Reading the Source ยท Card 4

How a view gets rendered after your action

Your index action ends, and a few milliseconds later the browser is looking at HTML. You never wrote a line that said "render". Here's what Rails actually did between those two moments.

1. If you didn't call render, Rails calls it for you.

When your action method returns, Rails checks whether you already rendered or redirected. If you didn't, Rails fills in a default render call using the controller name and the action name. That's why a bare action method still produces a page.

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end
end

# After this action method returns, Rails effectively does:
#
#   render :index
#
# Which is shorthand for:
#
#   render template: "posts/index"
#
# Where does "posts/index" come from?
#   - "posts"  comes from PostsController โ†’ :posts
#   - "index"  comes from the action name
#
# If you DID call render or redirect_to in the action, Rails
# notices that and skips the default render.

๐Ÿ“‚ actionpack/lib/action_controller/metal/implicit_render.rb

2. Rails looks for the template file.

Rails has a list of folders to search for templates, called the view paths. By default the list has one entry: app/views. Rails walks the list looking for a file that matches the name (posts/index), the request format (html), and an extension Rails knows how to render (.erb, .haml, .jbuilder, etc.). The first match wins.

# Looking for: posts/index, format: html

# Rails checks, in order:
#
#   app/views/posts/index.html.erb        โ† if this exists, stop here
#   app/views/posts/index.html.haml
#   app/views/posts/index.html.builder
#   ...
#
# If gems have added their own view paths (Devise, Active Admin, etc.),
# those get checked too, in the order they were added.

# This is also why partials work:
#   render "shared/header"
#
# Rails looks for app/views/shared/_header.html.erb (note the underscore).

๐Ÿ“‚ actionview/lib/action_view/path_set.rb

3. The file gets matched with a handler.

Different file extensions are rendered by different handlers. .erb files go through the ERB handler, .jbuilder files go through the Jbuilder handler, and so on. The handler's job is to turn the template source into Ruby code that, when run, produces the output string.

# Rails keeps a registry of handlers, roughly:

ActionView::Template.handler_for_extension("erb")
# => ActionView::Template::Handlers::ERB

ActionView::Template.handler_for_extension("jbuilder")
# => Jbuilder::Handler

# So app/views/posts/index.html.erb gets matched with the ERB handler.
# The handler reads the file's source and turns it into Ruby:

# Template:
#   <h1>Posts</h1>
#   <% @posts.each do |post| %>
#     <p><%= post.title %></p>
#   <% end %>

# Becomes (roughly) Ruby code like:
#   _output = +""
#   _output << "<h1>Posts</h1>\n"
#   @posts.each do |post|
#     _output << "  <p>"
#     _output << post.title.to_s
#     _output << "</p>\n"
#   end
#   _output

๐Ÿ“‚ actionview/lib/action_view/template/handlers/erb.rb

4. The compiled Ruby gets evaluated in a view context.

The compiled Ruby code from the template doesn't run in a vacuum. Rails creates a special object called the view context, copies all your controller's instance variables onto it (@posts, @user, etc.), and runs the compiled template as a method on that object. That's why view templates can read @posts, call helper methods, and use link_to.

# Conceptually:

view_context = ActionView::Base.new(view_paths, {}, controller)

# Copy controller's instance variables onto the view context:
controller.instance_variables.each do |name|
  value = controller.instance_variable_get(name)
  view_context.instance_variable_set(name, value)
end

# Define the compiled template as a method on the view context, then call it:
view_context.send(:_run_index_html_erb)
# => "<h1>Posts</h1>\n  <p>First post</p>\n  <p>Second post</p>\n"

# This is why @posts is visible inside the template: it's the same
# instance variable, copied over from the controller.
# It's also why view helpers (link_to, form_with, your own helpers)
# work: they're methods mixed into the view context's class.

๐Ÿ“‚ actionview/lib/action_view/base.rb and renderer/template_renderer.rb

5. Layouts wrap the rendered template.

The first render produces just the action's HTML. Rails then renders the layout the same way, and where the layout calls yield, Rails inserts the action's HTML. The layout file follows the same lookup rules as a template, with a default of app/views/layouts/application.html.erb.

# app/views/layouts/application.html.erb:
#
#   <html>
#     <body>
#       <%= yield %>
#     </body>
#   </html>

# Rendering happens in two passes:

inner = render_template("posts/index")
# => "<h1>Posts</h1>\n<p>First post</p>"

layout_html = render_template("layouts/application")
# At yield, Rails substitutes `inner`:
# => "<html>\n  <body>\n    <h1>Posts</h1>\n<p>First post</p>\n  </body>\n</html>"

# That final string becomes the response body.

๐Ÿ“‚ actionview/lib/action_view/renderer/template_renderer.rb

6. The HTML becomes the response body.

The rendered string gets set as the body of the controller's response. Rails sets the right Content-Type header for the format (text/html for HTML, application/json for JSON, etc.) and a status (200 by default). The response then walks back up through the middleware stack and out to the browser.

# At this point, the controller's response object looks like:

response.status  # => 200
response.headers # => { "Content-Type" => "text/html; charset=utf-8", ... }
response.body    # => "<html>\n  <body>\n    <h1>Posts</h1>..."

# This becomes the Rack response triple:
#   [200, headers_hash, [body_string]]
#
# Which travels back through every middleware in reverse order
# (Rack::Cors, ActionDispatch::Cookies, etc.) and finally to the browser.

Why this is worth knowing

Knowing the lookup โ†’ handler โ†’ context flow turns a lot of rendering surprises into routine debugging:

  • "Missing template" errors read clearly. The error tells you exactly which paths Rails searched and which format and handler it was looking for. The fix is almost always to add the missing file or change the format.
  • You understand why partials need an underscore. The template lookup specifically searches for _partial_name when you call render "partial_name". That's the file-naming rule, applied at lookup time.
  • You can override Devise/Active Admin/etc. views with confidence. View paths are an ordered list. Putting your own file in app/views/devise/... wins because app/views is earlier in the list than the gem's.
  • You see why instance variables work in views but local variables don't. Rails copies instance variables over to the view context. Local variables in your action are gone by the time the template runs. To pass a local, use render partial: "x", locals: { foo: bar }.