Recipe: Rails + Micra
How to drop Micra into a Rails app — manually (importmap + twelve lines of layout glue, zero gem dep) or via the micra-rails gem (ERB helpers + one-shot installer). Then a small Tasks board that demonstrates the five SSR-friendly patterns Micra is designed for: server-rendered props, CSRF-attached this.fetch, cross-component event bus, typed this.prop(), and no-flicker hydration.
1. Manual integration (no gem, ~12 lines total)
The Rails-native way: one importmap pin, one <script type="module"> in your application layout, one application.js. Works on any Rails ≥ 7.1 with importmap (the default since Rails 7).
a. Pin Micra in config/importmap.rb
# config/importmap.rb
pin "application"
pin "micra",
to: "https://cdn.jsdelivr.net/npm/micra.js@2.3.1/dist/micra.esm.js",
preload: true
b. Boot Micra from your application layout
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<%= csrf_meta_tags %>
<%= javascript_importmap_tags %>
<script type="module">
import * as Micra from "micra"
window.Micra = Micra // expose for non-module inline scripts
document.addEventListener("DOMContentLoaded", () => Micra.start())
document.addEventListener("turbo:load", () => Micra.start())
</script>
</head>
<body>
<%= yield %>
</body>
</html>
- CSRF is automatic. this.fetch() reads the token from csrf_meta_tags and adds X-CSRF-Token to every non-GET request.
- window.Micra assignment. Importmap modules don't leak symbols to the global scope, but you'll often want Micra.define() from a non-module inline <script> inside a partial. Exposing it on window keeps that ergonomic.
- turbo:load mirror. If you're using Turbo Drive (the default in Rails 7+), DOMContentLoaded only fires on the first page load. After a Turbo navigation, <body> is swapped but DOMContentLoaded does NOT fire again, so new [data-component] elements never mount. turbo:load is the canonical "page is ready" signal that fires on both fresh loads and soft navigations. Micra.start() is idempotent, so calling both is safe.
c. Define components in app/javascript/application.js
// app/javascript/application.js
import * as Micra from "micra"
Micra.define("counter", {
state: { count: 0 },
inc() { this.state.count++ },
dec() { this.state.count-- },
})
d. Use the component in a view
<%# app/views/welcome/index.html.erb %>
<div data-component="counter">
<button @click="dec">−</button>
<strong data-text="count"></strong>
<button @click="inc">+</button>
</div>
Done. The whole integration is twelve lines of ERB glue and one importmap pin.
2. Using the micra-rails gem (optional)
If the inline <script> block in your layout grows past comfort, or you find yourself building lots of components with server-rendered props, the micra-rails gem adds three helpers and a generator:
bundle add micra-rails
bin/rails generate micra:install
The installer pins Micra in config/importmap.rb and inserts <%= micra_includes %> into your application layout. After that:
<%# Same as 1d, but with the helper %>
<%= micra_component :counter, count: 0 do %>
<button @click="dec">−</button>
<strong data-text="count"></strong>
<button @click="inc">+</button>
<% end %>
Expands to <div data-component="counter" data-count="0">…</div> — exactly the markup you would write by hand. The helper auto-dasherizes prop names (current_user_id → data-current-user-id) and JSON-encodes non-primitive values (Hash, Array, ActiveRecord .as_json).
- micra_state(...).to_html as shown in the gem's README doesn't compile — Hash#to_html isn't a Rails method. Until the helper returns a SafeBuffer, wrap the element with micra_component instead, or expand the hash inline yourself.
- JSON-encoded props need client-side JSON.parse. When you pass a non-primitive (@user.as_json, @tasks.as_json), micra_component serializes it into the data-* attribute with to_json. But Micra's this.prop() returns the raw string — it doesn't auto-detect JSON. See the next section for the canonical seed-in-onCreate pattern.
- micra_includes does NOT add the turbo:load listener. It only wires DOMContentLoaded. If you're on Turbo Drive, append the second listener manually in application.js.
3. The Tasks board — five canonical patterns
A board with a list of tasks. Each task has a title and a done flag. Adding, toggling, and deleting are this.fetch(...) calls that return JSON. A separate component in the header — a "pending count" badge — listens to a tasks:changed event on the global bus.
a. Schema and controller
# db/migrate/…_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[8.0]
def change
create_table :tasks do |t|
t.string :title, null: false
t.boolean :done, null: false, default: false
t.timestamps
end
end
end
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def index
@tasks = Task.order(created_at: :asc)
respond_to do |fmt|
fmt.html # renders index.html.erb (initial SSR)
fmt.json { render json: @tasks.as_json(only: %i[id title done]) }
end
end
def create
task = Task.create!(title: params.require(:title))
render json: task.as_json(only: %i[id title done])
end
def update
task = Task.find(params[:id])
task.update!(params.permit(:done))
render json: task.as_json(only: %i[id title done])
end
def destroy
Task.find(params[:id]).destroy!
head :no_content
end
end
# config/routes.rb
resources :tasks, only: %i[index create update destroy]
Stock JSON controller. Micra sets Accept: application/json on this.fetch() calls, so the fmt.json branch handles them while fmt.html handles the initial SSR page load.
b. The ERB view — initial SSR
<%# app/views/tasks/index.html.erb %>
<header>
<div data-component="pending-count"
data-initial='<%= @tasks.count(&:not_done?) %>'>
<span data-text="count"></span> pending
</div>
</header>
<section data-component="tasks-board"
data-initial-tasks='<%= @tasks.as_json(only: %i[id title done]).to_json %>'>
<form @submit.prevent="add">
<input data-model="draft" placeholder="New task…" maxlength="200" />
<button data-bind="disabled:!draft.trim()">Add</button>
</form>
<ul>
<template data-each="tasks" data-key="id">
<li data-class="done:item.done">
<label>
<input type="checkbox" data-bind="checked:item.done" @change="toggle" />
<span data-text="item.title"></span>
</label>
<button @click="remove" data-bind="data-id:item.id">×</button>
</li>
</template>
</ul>
</section>
Two data-component roots — they are independent instances and only communicate through the global event bus. The data-initial-tasks attribute carries the full list as JSON for the component to seed state.tasks on onCreate.
c. The components
// app/javascript/components/tasks.js (imported from application.js)
import * as Micra from "micra"
Micra.define("tasks-board", {
state: { tasks: [], draft: "" },
onCreate() {
// SSR seed — data-initial-tasks holds JSON, parse once.
const raw = this.prop("initialTasks")
if (raw) this.state.tasks = JSON.parse(raw)
},
async add() {
const title = this.state.draft.trim()
if (!title) return
const task = await this.fetch("/tasks", { method: "POST", body: { title } })
this.state.tasks = [...this.state.tasks, task]
this.state.draft = ""
this.emit("tasks:changed", { tasks: this.state.tasks })
},
async toggle(e) {
const id = Number(e.currentTarget.closest("li").querySelector("[data-id]").dataset.id)
const next = !this.state.tasks.find(t => t.id === id).done
const task = await this.fetch(`/tasks/${id}`, { method: "PATCH", body: { done: next } })
this.state.tasks = this.state.tasks.map(t => t.id === id ? task : t)
this.emit("tasks:changed", { tasks: this.state.tasks })
},
async remove(e) {
const id = Number(e.currentTarget.dataset.id)
await this.fetch(`/tasks/${id}`, { method: "DELETE" })
this.state.tasks = this.state.tasks.filter(t => t.id !== id)
this.emit("tasks:changed", { tasks: this.state.tasks })
},
})
Micra.define("pending-count", {
state: { count: 0 },
onCreate() {
this.state.count = this.prop("initial", 0) // primitive prop — auto-cast to number
this.on("tasks:changed", ({ tasks }) => {
this.state.count = tasks.filter(t => !t.done).length
})
},
})
Subscriptions made with this.on are auto-removed on destroy() — no explicit onDestroy needed.
4. Seeding non-primitive state from server-rendered props
Primitives round-trip cleanly: data-count="3" → this.prop('count') returns 3 as a number. For arrays and objects, Micra deliberately does not assume JSON — the prop comes back as the literal string. The pattern is "parse in onCreate", which the component above already follows.
A no-flicker alternative for SSR-heavy pages: instead of seeding in onCreate (which runs in a microtask, after the initial render — see the hydration contract), inline the state into the definition so the very first render already matches the server's HTML:
<%# When you want truly zero flicker — define inline next to the view %>
<section data-component="tasks-board">
…
</section>
<script type="module">
import * as Micra from "micra"
Micra.define("tasks-board", {
state: {
tasks: <%= raw @tasks.as_json(only: %i[id title done]).to_json %>,
draft: "",
},
// …same methods as above, minus the onCreate seed
})
</script>
Trade-off: this couples one definition to one view, so reuse is harder. Use it where flicker matters (above-the-fold lists, dashboards); use the data-* + onCreate pattern everywhere else.
5. Turbo Drive, Turbo Streams, and Turbo Frames
The integration story changes by Turbo feature.
Turbo Drive — works with the turbo:load listener
Drive replaces <body> on click-through navigations without a full reload. The turbo:load listener in section 1b is all that's needed. After every soft navigation, Micra.start() rescans the new DOM; already-mounted instances are skipped (start() is idempotent).
Drive doesn't unmount instances — the <body> is swapped, the old DOM nodes are gone, but their JS-side __micraScan and event-bus subscriptions linger. For most apps this is fine (the GC catches them within a tick). If you have long-lived dashboards that navigate frequently and accumulate bus subscriptions, destroy on turbo:before-visit:
document.addEventListener("turbo:before-visit", () => {
Micra.instances().forEach(inst => inst.destroy())
})
Turbo Streams — server-driven swaps
Stream actions (<turbo-stream action="append" target="tasks">…</turbo-stream>) replace fragments of the DOM. New fragments from the server can contain [data-component] elements that need mounting. Listen to turbo:before-stream-render for cleanup and turbo:render for re-mount:
document.addEventListener("turbo:before-stream-render", (e) => {
const target = document.getElementById(e.target.target)
if (!target) return
Micra.instances().forEach((inst, root) => {
if (target.contains(root)) inst.destroy()
})
})
document.addEventListener("turbo:render", () => Micra.start())
Same shape as the htmx bridge: destroy before swap, mount after settle. If you're using Turbo Streams via WebSocket (the default for broadcast_* ActionCable hooks), the same listeners fire.
Turbo Frames — avoid co-locating with data-component
A <turbo-frame> with src="..." replaces its own innerHTML on navigation. If a [data-component] element is inside the frame, the swap leaves its cached __micraScan pointing at gone DOM — exactly the htmx footgun. Two ways out:
- Put data-component on a wrapper outside the <turbo-frame>, so the swap target is purely inside Micra-managed DOM.
-
Listen to
turbo:frame-render and
re-mount the frame's contents:
document.addEventListener("turbo:frame-render", (e) => { Micra.instances().forEach((inst, root) => { if (e.target.contains(root) && root !== e.target) inst.destroy() }) Micra.start(e.target) })
Things to avoid
- Don't put [data-component] directly on a <turbo-frame src=…> if the frame swaps its own innerHTML. Same root cause as putting hx-swap on a data-component. Wrap or use a separate frame target.
- Don't forget the turbo:load listener if you use Turbo Drive. Symptom: components on the landing page mount, components on every subsequent page do nothing. Easy to miss because the landing case works.
- Don't return HTML from a JSON endpoint. Micra's this.fetch() inspects Content-Type: if it includes application/json, you get parsed JSON; otherwise you get a string. Forgetting respond_to do |fmt| fmt.json will dump ERB into a string and break downstream.
- Don't return an empty body for non-2xx responses. Micra throws FetchError(message, status, response) on non-2xx. If your endpoint returns 422 { errors: {...} } for a validation failure, parse the body server-side: catch (e) { if (e instanceof FetchError) { const errs = await e.response.json(); ... } }.
- Don't put Micra.start() in a <script defer> if your layout also has turbo:load. Duplicate calls are safe but make initialization order harder to reason about. One source of truth — the module script in section 1b.
- Don't JSON-stringify primitives into data-* attributes. Numbers and booleans are already strings on the wire; let Micra's auto-cast pick them up (data-count="3" → this.prop('count') === 3). Only arrays / hashes need the JSON.parse round-trip.
Pairing with Stimulus
You can run Stimulus and Micra side-by-side on the same page — they don't fight. Different DOM trees, different identifiers (data-controller vs data-component). The clean split:
- Stimulus for stateless DOM behaviors — clipboard copy, scroll detection, table-row hover, dropdown open/close without needing reactive state.
- Micra for pages where state drives the UI — search-with-results, multi-step forms, dashboards, anything where a button click should ripple through three other elements.
If a controller is just "state + N event handlers + a template that re-renders", that's Micra. If it's "find this child, animate it, forget", that's Stimulus.
Skeleton (one-shot copy-paste)
For a fresh Rails 8 app with Micra wired in:
rails new tasks-demo
cd tasks-demo
# 1. Pin Micra
cat >> config/importmap.rb <<'EOF'
pin "micra",
to: "https://cdn.jsdelivr.net/npm/micra.js@2.3.1/dist/micra.esm.js",
preload: true
EOF
# 2. Boot block — copy into <head> of app/views/layouts/application.html.erb:
#
# <script type="module">
# import * as Micra from "micra"
# window.Micra = Micra
# document.addEventListener("DOMContentLoaded", () => Micra.start())
# document.addEventListener("turbo:load", () => Micra.start())
# </script>
# 3. Scaffold a resource
rails g scaffold Task title:string done:boolean
rails db:migrate
Then convert the generated index.html.erb to a data-component wrapper following section 3b, add the component to app/javascript/application.js following section 3c, and the board is live.