Rack::Component
Like a React.js component, a Rack::Component implements a render method that
takes input data and returns what to display. You can use Components instead of
Controllers, Views, Templates, and Helpers, in any Rack app.
Install
Add rack-component to your Gemfile and run bundle install:
gem 'rack-component'
Quickstart with Sinatra
# config.ru
require 'sinatra'
require 'rack/component'
class Hello < Rack::Component
render do |env|
"<h1>Hello, #{h(env[:name])}</h1>"
end
end
get '/hello/:name' do
Hello.call(name: params[:name])
end
run Sinatra::Application
Note that Rack::Component currently does not escape strings by default. To
escape strings, you must use the #h helper, as in the example above.
There is an issue open to discuss how to enable escaping by default. If you have ideas or opinions, I'd love to hear about them there.
Table of Contents
- Getting Started
- Recipes
- Render one component inside another
- Memoize an expensive component for one minute
- Memoize an expensive component until its content changes
- Render an HTML list from an array
- Render a Rack::Component from a Rails controller
- Mount a Rack::Component as a Rack app
- Build an entire App out of Rack::Components
- API Reference
- Performance
- Compatibility
- Anybody using this in production?
- Ruby reference
- Development
- Contributing
- License
Getting Started
Components as plain functions
The simplest component is just a lambda that takes an env parameter:
Greeter = lambda do |env|
"<h1>Hi, #{env[:name]}.</h1>"
end
Greeter.call(name: 'Mina') #=> '<h1>Hi, Mina.</h1>'
Components as Rack::Components
Convert your lambda to a Rack::Component when it needs instance methods or
state:
require 'rack/component'
class FormalGreeter < Rack::Component
render do |env|
"<h1>Hi, #{title} #{env[:name]}.</h1>"
end
def title
# the hash you pass to `call` is available as `env` in instance methods
env[:title] || "President"
end
end
FormalGreeter.call(name: 'Macron') #=> "<h1>Hi, President Macron.</h1>"
FormalGreeter.call(name: 'Merkel', title: 'Chancellor') #=> "<h1>Hi, Chancellor Merkel.</h1>"
Components that re-render instantly
Replace #call with #memoized to make re-renders with the same env instant:
require 'rack/component'
require 'net/http'
class NetworkGreeter < Rack::Component
render do |env|
"Hi, #{get_job_title_from_api} #{env[:name]}."
end
def get_job_title_from_api
endpoint = URI("http://api.heads-of-state.gov/")
Net::HTTP.get("#{endpoint}?q=#{env[:name]}")
end
end
NetworkGreeter.memoized(name: 'Macron')
# ...after a slow network call to our fictional Heads Of State API
#=> "Hi, President Macron."
NetworkGreeter.memoized(name: 'Macron') # subsequent calls with the same env are instant.
#=> "Hi, President Macron."
NetworkGreeter.memoized(name: 'Merkel')
# ...this env is new, so NetworkGreeter makes another network call
#=> "Hi, Chancellor Merkel."
NetworkGreeter.memoized(name: 'Merkel') #=> instant! "Hi, Chancellor Merkel."
NetworkGreeter.memoized(name: 'Macron') #=> instant! "Hi, President Macron."
Recipes
Render one component inside another
You can nest Rack::Components as if they were React Children by calling them with a block.
Layout.call(title: 'Home') { Content.call }
Here's a more fully fleshed example:
require 'rack/component'
# let's say this is a Sinatra app:
get '/posts/:id' do
PostPage.call(id: params[:id])
end
# fetch a post from the database and render it inside a layout
class PostPage < Rack::Component
render do |env|
post = Post.find(id: env[:id])
# Nest a PostContent instance inside a Layout instance, with some arbitrary HTML too
Layout.call(title: post.title) do
" <main>\n \#{PostContent.call(title: post.title, body: post.body)}\n <footer>\n I am a footer.\n </footer>\n </main>\n HTML\n end\n end\nend\n\nclass PostContent < Rack::Component\n render do |env|\n <<~HTML\n <article>\n <h1>\#{env[:title]}</h1>\n \#{env[:body]}\n </article>\n HTML\n end\nend\n\nclass Layout < Rack::Component\n render do |env, &children|\n # the `&children` param is just a standard ruby block\n <<~HTML\n <!DOCTYPE html>\n <html>\n <head>\n <title>\#{env[:title]}</title>\n </head>\n <body>\n \#{children.call}\n </body>\n </html>\n HTML\n end\nend\n"
Memoize an expensive component for one minute
You can use memoized as a time-based cache by passing a timestamp to env:
require 'rack/component'
# Render one million posts as JSON
class MillionPosts < Rack::Component
render { |env| Post.limit(1_000_000).to_json }
end
MillionPosts.memoized(Time.now.to_i / 60) #=> first call is slow
MillionPosts.memoized(Time.now.to_i / 60) #=> next calls in same minute are quick
Memoize an expensive component until its content changes
This recipe will speed things up when your database calls are fast but your render method is slow:
require 'rack/component'
class PostAnalysis < Rack::Component
render do |env|
" <h1>\#{env[:post].title}</h1>\n <article>\#{env[:post].content}</article>\n <aside>\#{expensive_natural_language_analysis}</aside>\n HTML\n end\n\n def expensive_natural_language_analysis\n FancyNaturalLanguageLibrary.analyze(env[:post].content)\n end\nend\n\nPostAnalysis.memoized(post: Post.find(1)) #=> slow, because it runs an expensive natural language analysis\nPostAnalysis.memoized(post: Post.find(1)) #=> instant, because the content of :post has not changed\n"
This recipe works with any Ruby object that implements a #hash method based
on the object's content, including instances of ActiveRecord::Base and
Sequel::Model.
Render an HTML list from an array
JSX Lists use JavaScript's map function. Rack::Component does
likewise, only you need to call join on the array:
require 'rack/component'
class PostsList < Rack::Component
render do |env|
" <h1>This is a list of posts</h1>\n <ul>\n \#{render_items}\n </ul>\n HTML\n end\n\n def render_items\n env[:posts].map { |post|\n <<~HTML\n <li class=\"item\">\n <a href=\"\#{post[:url]}\">\n \#{post[:name]}\n </a>\n </li>\n HTML\n }.join #unlike JSX, you need to call `join` on your array\n end\nend\n\nposts = [{ name: 'First Post', id: 1 }, { name: 'Second', id: 2 }]\nPostsList.call(posts: posts) #=> <h1>This is a list of posts</h1> <ul>...etc\n"
Render a Rack::Component from a Rails controller
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
render json: PostsList.call(params)
end
end
# app/components/posts_list.rb
class PostsList < Rack::Component
render { |env| posts.to_json }
def posts
Post.magically_filter_via_params(env)
end
end
Mount a Rack::Component as a Rack app
Because Rack::Components follow the same API as a Rack app, you can mount them anywhere you can mount a Rack app. It's up to you to return a valid rack tuple, though.
# config.ru
require 'rack/component'
class Posts < Rack::Component
render do |env|
[status, headers, [body]]
end
def status
200
end
def headers
{ 'Content-Type' => 'application/json' }
end
def body
Post.all.to_json
end
end
run Posts
Build an entire App out of Rack::Components
In real life, maybe don't do this. Use Roda or Sinatra for routing, and use Rack::Component instead of Controllers, Views, and templates. But to see an entire app built only out of Rack::Components, see the example spec.
API Reference
The full API reference is available here:
https://www.rubydoc.info/gems/rack-component
For info on how to clear or change the size of the memoziation cache, please see the spec.
Performance
On my machine, Rendering a Rack::Component is almost 10x faster than rendering a
comparable Tilt template, and almost 100x faster than ERB from the Ruby standard
library. Run ruby spec/benchmarks.rb to see what to expect in your env.
$ ruby spec/benchmarks.rb
Warming up --------------------------------------
Ruby stdlib ERB 2.807k i/100ms
Tilt (cached) 28.611k i/100ms
Lambda 249.958k i/100ms
Component 161.176k i/100ms
Component [memoized] 94.586k i/100ms
Calculating -------------------------------------
Ruby stdlib ERB 29.296k (
Notice that using Component#memoized is slower than using Component#call
in this benchmark. Because these components do almost nothing, it's more work to
check the memoziation cache than to just render. For components that don't
access a database, don't do network I/O, and aren't very CPU-intensive, it's
probably fastest not to memoize. For components that do I/O, using #memoize
can speed things up by several orders of magnitude.
Compatibility
Rack::Component has zero dependencies, and will work in any Rack app. It should
even work outside a Rack app, because it's not actually dependent on Rack. I
packaged it under the Rack namespace because it follows the Rack call
specification, and because that's where I use and test it.
Anybody using this in production?
Aye:
Ruby reference
Where React uses JSX to make components more ergonomic, Rack::Component leans heavily on some features built into the Ruby language, specifically:
Development
After checking out the repo, run bin/setup to install dependencies. Then, run
rake spec to run the tests. You can also run bin/console for an interactive
prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To
release a new version, update the version number in version.rb, and then run
bundle exec rake release, which will create a git tag for the version, push
git commits and tags, and push the .gem file to
rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/chrisfrank/rack-component.
License
MIT