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.

importmap-rails Turbo Drive CSRF auto Stimulus-compatible
CSRF is already wired. Rails ships <%= csrf_meta_tags %> in the default layout. Micra's this.fetch() reads <meta name="csrf-token"> and sends X-CSRF-Token on every non-GET request — no extra configuration, no protect_from_forgery workarounds.

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_iddata-current-user-id) and JSON-encodes non-primitive values (Hash, Array, ActiveRecord .as_json).

Caveats to be aware of as of v0.3.0.
  • 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.
None of these block the gem from being useful — they just need to be known.

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:

  1. Put data-component on a wrapper outside the <turbo-frame>, so the swap target is purely inside Micra-managed DOM.
  2. 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.