RSX — JSX for Ruby
RSX is a template language that brings React's JSX authoring model to Ruby. You write .rsx
files in which Ruby replaces JavaScript and markup is embedded directly in expression
position:
component Greeting do |name:, admin: false|
return (
<>
<h1 className="title">Hello, {name}!</h1>
{admin ? <p>Admin privileges active.</p> : <p>Standard account.</p>}
</>
)
end
export default Greeting
Example: RSX with Tailwind CSS
Templates are compiled ahead of time into plain Ruby string building, so rendering is concatenation and escaping — no interpreter, no virtual DOM, no diffing. RSX has zero runtime dependencies; the Rails integration activates itself only when Rails is already loaded.
- Installation
- Quick start
- Two kinds of
.rsxfile - The language
- Attributes
- Components
- Context
- Prerendering and caching
- Rails integration
- Using RSX without Rails
- Command line
- Configuration reference
- Differences from React
- Errors and debugging
- Testing
Why RSX
Ruby's view layer has always been a string templating language with tags bolted on (<%= %>,
= in Slim, indentation in Haml). JSX took the opposite approach: markup is a first-class
expression in the host language, so ordinary language constructs — variables, methods,
conditionals, loops, composition — are all you need to learn.
RSX is that model, in Ruby:
| Goal | How |
|---|---|
| Familiar to anyone who knows JSX | Same syntax: <> fragments, {} containers, className, spread attributes, components as capitalized tags, import/export default |
| Fast enough for the request path | .rsx compiles to Ruby once; static markup collapses into single frozen literals |
| Cacheable like ViewComponent, without being ViewComponent | Whole-component caching, fragment caching, static prerendering, on-disk compile cache |
| Small surface area | No runtime dependencies; ~3k lines of Ruby; the compiler is a single-pass scanner |
| Debuggable | Generated Ruby preserves your line numbers, so backtraces point at the .rsx file |
The example from React, ported
| UserProfile.jsx | user_profile.rsx |
|---|---|
|
|
The full version lives in examples/user_profile.rsx.
Installation
Add the gem to your Gemfile:
gem "rsx-rb"
Then:
bundle install
Or install it directly:
gem install rsx-rb
The published gem is rsx-rb because rsx is already taken on RubyGems. The library is still require "rsx" — Bundler does that automatically.
RSX requires Ruby 3.0+. It has no runtime dependencies. In a Rails app — ActionView is the only part RSX touches, and the suite runs against 7.1 — the railtie loads automatically and:
- registers the
.rsxtemplate handler with ActionView, soapp/views/**/*.html.rsxjust works; - mixes
RSX::Helpersinto ActionView, giving yoursxandrsx_file; - looks for components in
app/componentsandapp/rsx; - caches compiled output in
tmp/cache/rsx; - eager loads components in production and reloads changed files in development;
- adds the
rsx:precompile,rsx:clearandrsx:componentsrake tasks.
Nothing else is required. To change the defaults, see Configuration reference.
Quick start
1. A view
# app/views/pages/home.html.rsx
<section className="hero">
<h1>{@title}</h1>
<p>Signed in as {current_user.email}</p>
{link_to "Docs", docs_path, class: "btn"}
</section>
Render it from a controller exactly as you would an ERB view:
class PagesController < ApplicationController
def home
@title = "Welcome"
end
end
Inside a view, self is the Rails view context: controller instance variables, link_to,
form_with, t, render partial: and every other helper are available directly.
2. A component
# app/components/badge.rsx
component Badge do |label:, tone: "neutral"|
return {label}
end
export default Badge
Use it from any .rsx file by writing it as a tag:
# app/views/pages/home.html.rsx
import Badge from "badge"
<p>Status: <Badge label="Live" tone="success" /></p>
…or from ERB, Haml or Slim with the rsx helper:
<%= rsx Badge, label: "Live", tone: "success" %>
…or from anywhere in Ruby:
Badge.call(label: "Live") # => "<span class=\"badge badge-neutral\">Live</span>"
RSX.render(Badge, label: "Live") # same, and accepts context:
Two kinds of .rsx file
This is the only structural concept RSX adds, and it mirrors the difference between a JSX module and a JSX entry point.
A component file contains one or more component Name do ... end declarations. It is
evaluated once, at the top level, exactly like a .rb file — so constants, class, def and
require behave normally. Components become constants you can reference anywhere.
# app/components/alert.rsx
component Alert do |message:|
return <div className="alert" role="alert">{message}</div>
end
export default Alert
A template file contains markup at its top level and no component declarations. Its body is
compiled into a render method, so it can use props and — in Rails — the view context.
# app/views/posts/show.html.rsx
<article>
<h1>{@post.title}</h1>
{@post.body}
</article>
RSX decides which is which by looking at the compiled output, so you never declare it. Both
kinds may import other files.
Because the top level of a template file is Ruby,
{...}there is a Ruby hash, not an expression container. Expression containers only exist inside markup. Write plain Ruby at the top level:# not this: {@post ? <article /> : <p>None</p>} @post ? <article>{@post.title}</article> : <p>None</p>
The language
Everything below is compiled at load time. There is no runtime template parsing.
Markup in expression position
A < begins markup wherever Ruby expects a value: after return, =, (, ,, &&, inside a
block, and so on. Everywhere else < stays a Ruby operator, so a < b, a << b, a <=> b,
class Foo < Bar and heredocs (<<~SQL) are untouched.
title = <h1>Hi</h1> # assignment
rows = items.map { |i| <li>{i}</li> } # block body
return <p>{n < 10 ? "few" : "many"}</p> # comparison inside a container
Fragments
Multiple sibling elements need one parent. Use <>...</> when you do not want a wrapper
element (<Fragment> and <React.Fragment> are accepted too):
return (
<>
<dt>Term</dt>
<dd>Definition</dd>
</>
)
Expression containers
{} inside markup interpolates any Ruby expression. Values are escaped unless already marked
safe. Following React: nil, true and false render nothing, arrays are concatenated, and
everything else is converted with to_s.
<p>{user.name}</p>
<p>{format("%.2f", total)}</p>
<p>{items.sum { |i| i.price }}</p>
<p>{"admin" if user.admin?}</p>
Conditionals
Any Ruby conditional works, because a container holds an expression:
<div>
{user.admin? ? <Admin /> : <Standard />} {/* ternary, as in JSX */}
{user.admin? && <p>Danger zone</p>} {/* && shortcut, as in JSX */}
{notice.presence && <Alert message={notice} />}
{if user.admin? {/* or an if/else expression */}
<Admin />
elsif user.staff?
<Staff />
else
<Standard />
end}
{case status
when :ok then <Ok />
when :error then <Err />
end}
</div>
Lists
Return markup from any enumerable method. key is accepted (for parity with JSX) and not
rendered:
<ul>
{users.map { |user| <li key={user.id}>{user.name}</li> }}
</ul>
<tbody>
{rows.each_with_index.map do |row, index|
<tr className={index.even? ? "even" : "odd"}>
<td>{row.label}</td>
</tr>
end}
</tbody>
Comments
{/* ... */} inside markup is removed at compile time, and Ruby # comments work in Ruby
position (including inside a tag's attribute list):
<div>
{/* not emitted */}
<img
src={url}
alt="" # a Ruby comment, also fine here
/>
</div>
Text and whitespace
RSX applies JSX's whitespace rules: indentation-only lines are dropped and remaining lines are joined with a single space. So this…
<p>
Hello,
world
</p>
…renders <p>Hello, world</p>. Use {" "} when you need a space JSX would have collapsed.
Escaping and raw HTML
Interpolated values are HTML-escaped. Strings already marked safe (RSX's own output, and
anything answering html_safe?, such as ActiveSupport::SafeBuffer) pass through untouched.
<p>{"<b>"}</p> # => <p><b></p>
<p>{raw("<b>bold</b>")}</p> # => <p><b>bold</b></p>
<div dangerouslySetInnerHTML={{ __html: markdown }} /> # React's escape hatch
Attributes
Attribute values are either a quoted string or a {ruby} container. As in JSX, never both
(className="{x}" is a literal string).
Names
React's prop spellings are translated to HTML: className → class, htmlFor → for,
tabIndex → tabindex, httpEquiv → http-equiv, strokeWidth → stroke-width, and so on.
Case-sensitive SVG attributes (viewBox, preserveAspectRatio, …) keep their spelling. Names
that are already lowercase, snake_case or kebab-case pass through unchanged, so
data-controller="modal" works as written.
<label htmlFor="email" className="lbl">Email</label>
# => <label for="email" class="lbl">Email</label>
className
Accepts a String, Symbol, Array or Hash, and flattens nested combinations. Hash keys are included when their value is truthy:
<div className={["card", size, { selected: selected?, "is-new" => new? }]}></div>
style
Accepts a String or a Hash of CSS properties. camelCase and snake_case keys become
kebab-case, and numbers get px unless the property is unitless (z-index, line-height,
opacity, flex-grow, …):
<p style={{ backgroundColor: "pink", marginTop: 8, zIndex: 3 }}></p>
# => <p style="background-color:pink;margin-top:8px;z-index:3"></p>
Booleans
HTML boolean attributes are rendered bare when truthy and dropped when falsy. Non-boolean
attributes given true render ="true":
<input type="checkbox" checked disabled={false} required={true} />
# => <input type="checkbox" checked required>
data and aria
Pass a Hash to expand it into prefixed attributes. Arrays and Hashes are serialized as JSON.
Following React, booleans become the strings "true"/"false", and nil drops the attribute:
<div data={{ user_id: 7, ids: [1, 2] }} aria={{ label: "Close", hidden: true }}></div>
# => <div data-user-id="7" data-ids="[1,2]" aria-label="Close" aria-hidden="true"></div>
Spread
Both the JSX and the Ruby spelling are accepted:
<a {...attrs} className="link">x</a>
<a {**attrs} className="link">x</a>
Attributes on an element with a spread are merged the way React merges props: names that map to
the same HTML attribute collapse, keeping the last value. So className="link" above overrides
a class or className coming from attrs, rather than emitting the attribute twice. A nil
or false spread contributes nothing.
Spread works on components too, where it becomes keyword arguments.
Event handlers
There is no client-side runtime, so handlers are strings — the value of an HTML attribute:
<button onClick={"openSettings()"}>Settings</button>
# => <button onclick="openSettings()">Settings</button>
For real interactivity, use the attributes your JS framework expects
(data-controller, data-action, hx-post, …) — they pass through untouched.
Void and self-closing elements
Void elements never get a closing tag, whether or not you write /:
<br /> # => <br>
<img src={u}> # => <img src="...">
<circle r={4} /> # SVG keeps XML self-closing syntax => <circle r="4"/>
Components
Defining
component Name[, options] do |parameters|
...
return <markup />
end
The block body becomes the component's render method, so return is optional but reads well
with a parenthesized markup block. Component names must be constants; nesting is supported
(component Admin::Panel do).
Props
Declare props as keyword parameters — required, optional with defaults, or collected:
component Button do |label:, variant: "primary", disabled: false, **rest|
return <button className={["btn", "btn-#{variant}"]} disabled={disabled} {**rest}>{label}</button>
end
label:is required. Omitting it raisesRSX::PropsErrornaming the component.variant:has a default.**restcollects anything else, so callers can addid,data-*orariaattributes without the component knowing about them.- Without
**rest, passing an undeclared prop raisesRSX::PropsErrorlisting what is declared. This is RSX's substitute forpropTypes: mistakes surface immediately.
For a component that just forwards everything, take a single positional parameter and read the props hash:
component Debug do |props|
return <pre>{props.inspect}</pre>
end
Children
Markup nested inside a component tag arrives as the children: prop:
component Card do |title:, children: nil|
return (
<section className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</section>
)
end
<Card title="Hello">
<p>Anything at all.</p>
</Card>
Children are lazy: they are rendered when interpolated, not when passed. That is what makes
context providers, caching and conditional slots work correctly. children? tells you whether
any were given:
component Panel do |children: nil|
return <div>{children? ? children : <p className="empty">Nothing here</p>}</div>
end
Slots
A slot is just a prop holding markup, so no extra API is needed:
<Card title="Report" footer={<a href="/export">Export</a>}>
<Chart data={@data} />
</Card>
Render props
If the only child is an expression, it is passed through unrendered — so a lambda child becomes a render prop, as in React:
component List do |items:, children: nil|
return <ul>{items.map { |item| <li>{children.call(item)}</li> }}</ul>
end
<List items={@users}>
{->(user) { <a href={user_path(user)}>{user.name}</a> }}
</List>
Composition, import and export
Files reference each other with JSX's module syntax. Paths are resolved against the configured
paths (and relative to the importing file), with the .rsx and .html.rsx extensions optional:
import Button from "components/button" # default export, bound to `Button`
import { Card, CardList } from "components/card"
import "components/registers_many_components" # load for side effects
component Toolbar do |props|
return <div><Button label="Save" /><Card title="Recent" /></div>
end
export default Toolbar # what `import X from "..."` binds
export Toolbar # also part of this file's public list
Components are plain constants, so import is a convenience, not a requirement: anything
already loaded (in Rails, everything under the configured paths) can be used by name. Dotted and
namespaced tags work too: <Admin::Panel />, <Layout.Header />.
Any object that responds to rsx_call(props, parent) can be rendered as a tag, and a Proc can
be used as a component:
Spacer = ->(props) { <hr className="spacer" /> }
Rendering from Ruby
Badge.call(label: "Live") # keyword props
Badge.render(label: "Live") # alias
RSX.render(Badge, label: "Live") # component, lambda, or name
RSX.render("components/badge", label: "x")# a file path
RSX.render_file("app/views/x.html.rsx") # a template file
RSX.render_source("<p>{props[:a]}</p>", a: 1) # source, handy in tests
All of them return an RSX::SafeString, which reports html_safe? and escapes anything unsafe
concatenated onto it.
Context
React's Context API, for values that would otherwise be threaded through every component:
# app/components/theme.rsx
Theme = RSX.create_context("light", name: "Theme")
component ThemedPanel do |children: nil|
theme = use_context(Theme)
return <div className={["panel", "panel-#{theme}"]}>{children}</div>
end
<Theme.Provider value={"dark"}>
<ThemedPanel>Rendered dark, however deep it is nested.</ThemedPanel>
</Theme.Provider>
Provided values live on a per-thread stack and are popped when the provider finishes, so
concurrent requests never observe each other's context. Outside any provider, use_context
returns the default. You can also push a value from plain Ruby:
Theme.with("dark") { render_something }
Theme.value # => "light" again
Prerendering and caching
RSX is built so that a request pays for as little as possible. There are four layers, from cheapest to most general.
1. Compilation happens before the request
.rsx is transformed into Ruby once and cached on disk (tmp/cache/rsx in Rails), keyed by a
digest of the source and the compiler version. Warm it at deploy time:
bin/rails rsx:precompile
In production the railtie also eager loads every component at boot, so no request ever compiles
a template. RSX.precompile! does the same thing outside of rake.
2. Static markup collapses into one frozen literal
Markup with no interpolation becomes a single string, allocated once per call site and reused for the life of the process:
# source
<div className="card"><h1>Hi</h1></div>
# compiled
(::RSX::STATICS[:"1c4f972a7c-1"] ||= ::RSX.static("<div class=\"card\"><h1>Hi</h1></div>"))
Dynamic markup keeps its static parts inline, so there is one string build and no intermediate objects per element:
# source
<p>{name}</p>
# compiled
::RSX::SafeString.new("<p>#{::RSX.child((name))}</p>")
3. Static components render once
When a component's body is nothing but static markup, the compiler marks it and its output is
memoized after the first render. This is automatic; static: true states it explicitly:
component Divider, static: true do
return <hr className="rule" />
end
4. Component and fragment caching
Cache a whole component, keyed by its props plus a digest of its source file (so editing the component invalidates its entries):
component Sidebar, cache: { expires_in: 300 } do |section:|
...
end
cache: accepts true, a number of seconds, a Hash of expires_in:/key:, or a lambda used
as the key:
component UserCard, cache: { key: ->(props) { [props[:user], I18n.locale] }, expires_in: 1.hour } do |user:|
...
end
Cache just the expensive part of a body with the cache helper:
component Page do |user:|
return (
<div>
<h1>{user.name}</h1>
{cache(["stats", user], expires_in: 60) do
<ExpensiveStats user={user} />
end}
</div>
)
end
Cache keys are built from any Ruby value, using cache_key_with_version / cache_key /
id+updated_at when available — so passing an ActiveRecord model does the right thing.
Cache stores
The default store is a thread-safe in-process LRU (RSX::Cache::Memory), which needs no
configuration. In Rails, point RSX at Rails.cache to share invalidation with the rest of the
app:
config.rsx.cache_store = :rails # or :memory, :null, or any object with fetch/read/write/clear
Anything responding to fetch(key, expires_in:) { }, read, write, delete and clear
qualifies, so Redis or Memcached need no adapter.
Rails integration
Views, partials and layouts
Any view, partial or layout can be .html.rsx. Inside one, self is the view context:
# app/views/posts/show.html.rsx
<article className="post">
<h1>{@post.title}</h1>
{render(partial: "posts/byline", locals: { author: @post.author })}
<footer>{link_to "All posts", posts_path}</footer>
</article>
# app/views/posts/_byline.html.rsx
<p className="byline" data={{ author_id: author.id }}>{author.name}</p>
Partial locals are local variables, exactly as in ERB. Output is html-safe, so .rsx and ERB
templates can render each other freely.
Components from ERB, Haml or Slim
<%= rsx Badge, label: "Live" %>
<%= rsx "Badge", label: "Live" %> <%# by name, autoloaded on demand %>
<%= rsx Card, title: "Hello" do %>
<p>This ERB block becomes the component's children.</p>
<% end %>
<%= rsx_file "views/marketing/hero", plan: @plan %>
Rails helpers from inside a component
Components are not views, so they get the view context explicitly through helpers (aliased
view_context) — the nearest non-component ancestor:
component PostLink do |post:|
return <a href={helpers.post_path(post)}>{post.title}</a>
end
helpers? reports whether one is available. When you render a component outside a request, pass
one in: RSX.render(PostLink, context: view, post: post).
Configuration
# config/application.rb
config.rsx.paths = [Rails.root.join("app/components"), Rails.root.join("app/rsx")]
config.rsx.cache_store = :rails
config.rsx.cache_dir = Rails.root.join("tmp/cache/rsx")
config.rsx.component_namespace = Object # e.g. Components to namespace every component
config.rsx.reload = !Rails.env.production?
Directories on config.rsx.paths are added to Rails' file watcher, so editing a component in
development reloads just that file.
Rake tasks
bin/rails rsx:precompile # compile every .rsx file and warm the on-disk cache
bin/rails rsx:clear # delete compiled output and clear the render cache
bin/rails rsx:components # list every file and the components it defines
Using RSX without Rails
RSX is a plain Ruby library; nothing above requires Rails.
require "rsx"
RSX.configure do |config|
config.paths = ["components"]
config.cache_dir = "tmp/rsx" # nil to compile in memory only
config.cache_store = RSX::Cache::Memory.new
end
RSX.load("components/badge.rsx") # or RSX.load_all
puts Badge.call(label: "Live")
puts RSX.render_file("pages/index.rsx", title: "Home")
In Sinatra, Roda or Rack, RSX.render_file(path, context: self, **props) is usually all you
need — the context object is what helpers returns inside components.
Command line
rsx compile app/components/button.rsx # print the Ruby a file compiles to
rsx render app/views/home.html.rsx -p title=Hi
rsx precompile app # warm the on-disk compile cache
rsx version
Options: -I/--include PATH adds a directory to the load path, -c/--cache-dir DIR chooses
where compiled output goes, -p/--prop NAME=VALUE passes a string prop.
rsx compile is the fastest way to understand what RSX is doing — the output is ordinary Ruby.
Configuration reference
| Setting | Default | Meaning |
|---|---|---|
paths |
app/components, app/rsx (Rails) |
Directories searched for .rsx files and imports |
cache_dir |
tmp/cache/rsx |
Where compiled Ruby is stored; nil compiles in memory |
cache_store |
RSX::Cache::Memory |
Store for component and fragment caches |
component_namespace |
Object |
Module that component Name constants are defined under |
reload |
true outside production |
Reload changed .rsx files between requests |
Useful entry points on the RSX module: compile, load, load_all, reload!,
precompile!, render, render_file, render_source, template, lookup_component,
create_context, cache, config, configure, reset!.
Differences from React
RSX mirrors JSX's authoring model completely. It is not a client-side framework, so the runtime differences are worth stating plainly:
- Server-side only. There is no state, no hooks, no effects, no re-rendering. A component is
a function from props to HTML.
useState,useEffectand friends have no analogue. - Event handlers are strings, not functions:
onClick={"submit()"}becomes anonclickattribute. Pair RSX with Hotwire, Stimulus, htmx or Alpine for behavior. keyis accepted and ignored. There is no reconciliation to help.- Ruby, not JavaScript, inside
{}— souser[:name]rather thanuser.namefor hashes,&&/||semantics differ around0and"", andnilreplacesnull/undefined. - Expression containers only exist inside markup. At the top level of a template file,
{}is a Ruby hash. - Whitespace, escaping, fragments, spread,
dangerouslySetInnerHTML,className/stylehandling, boolean anddata/ariaattributes, children, render props, context, andimport/export defaultall behave as they do in React.
Errors and debugging
Compiled Ruby preserves the line numbers of the original .rsx file, so exceptions raised while
rendering point at the source you wrote:
app/components/user_table.rsx:14:in `rsx_render': undefined method `name' for nil (NoMethodError)
RSX raises a small set of errors, all descending from RSX::Error:
| Error | Cause |
|---|---|
RSX::SyntaxError |
Malformed markup, with file and line: unterminated tag, missing }, unclosed element |
RSX::PropsError |
A missing required prop, or an undeclared prop on a component without **rest |
RSX::UnknownComponentError |
A tag that resolves to nothing renderable |
RSX::FileNotFoundError |
An import or path that cannot be resolved, listing where RSX looked |
When something renders unexpectedly, rsx compile FILE (or RSX.compile(source)) shows the
generated Ruby, which is usually enough to see what happened.
Testing
Components are plain Ruby objects, so they can be tested without a request or a view:
require "rsx"
class BadgeTest < Minitest::Test
def setup
RSX.config.paths = ["app/components"]
RSX.load("app/components/badge.rsx")
end
def test_renders_the_label
assert_equal %(<span class="badge badge-neutral">Live</span>), Badge.call(label: "Live").to_s
end
end
RSX.render_source renders a string of .rsx directly, which keeps markup tests to one line:
assert_equal "<p><b></p>", RSX.render_source("<p>{props[:x]}</p>", x: "<b>").to_s
RSX's own suite (compiler, runtime, components, caching, Rails integration, and every file in
examples/) runs with:
rake test
Examples
| File | Shows |
|---|---|
examples/user_profile.rsx |
The React example above, ported: variables, methods, inline styles, ternaries |
examples/components/button.rsx |
Prop defaults, required props, pass-through **rest |
examples/components/card.rsx |
Children and markup-valued slot props |
examples/components/user_table.rsx |
Loops, computed classes, inline styles, helper methods, empty states |
examples/components/sidebar.rsx |
Component caching and fragment caching |
examples/components/theme.rsx |
Context providers and consumers |
examples/views/dashboard.html.rsx |
A Rails view composing all of the above |
License
MIT. See LICENSE.txt.