October 14, 2026
Micra on Rails: small components in a Rails admin
In one of my projects, a plain Rails app — controllers, ERB views, Turbo Drive left on as the generator set it up — I did it like this. The public pages don’t use Micra. The admin where I write posts is a different story: there are a handful of spots where the page has to react while I type, and those spots run on Micra.
There are six components in total, and most of them are small. The menu toggle is six lines. Below are the layout that wires Micra into Turbo and three components that turned out to be interesting for different reasons: an autosave that keeps drafts in localStorage, a character counter, and tag autocomplete. The code is what runs in that project, trimmed of a few comments. The admin’s interface is in Russian; I’ve translated the strings.
The layout and Turbo
Micra comes in through a plain <script> tag, and the components through importmap:
<%# app/views/layouts/dashboard.html.erb %>
<%= csrf_meta_tags %>
<script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
<%= javascript_importmap_tags %>
<script type="module" nonce="<%= content_security_policy_nonce %>">
import "dashboard"
const mount = () => Micra.start()
document.addEventListener("DOMContentLoaded", mount)
// Turbo Drive swaps <body> without a reload; DOMContentLoaded won't fire again
document.addEventListener("turbo:load", mount)
// tear down live components before Turbo replaces the page
document.addEventListener("turbo:before-visit", () => {
Micra.instances().forEach(inst => inst.destroy())
})
</script>
# config/importmap.rb
pin "dashboard"
pin_all_from "app/javascript/dashboard", under: "dashboard"
app/javascript/dashboard.js is just six imports, one per component file. Each file calls Micra.define, and Micra.start() mounts whatever [data-component] elements the current page has.
Two of the three listeners are about Turbo. turbo:load fires on the first load and after every soft navigation, so components on the second page you open get mounted too; without it they sit there dead, and only the page you landed on works. Micra.start() is idempotent, so calling it from both events does no harm.
The turbo:before-visit line is the cleanup. When Turbo swaps the body, the component elements disappear, but whatever a component set up outside its own element — a timer, a listener on window — would keep running. Destroying every instance before the visit runs each onDestroy. Since 2.7 there’s a general tool for the same job, Micra.autoCleanup(): it watches the DOM and destroys any component whose element left the document, whatever removed it. My layout predates it and still works, so I haven’t touched it.
Autosave: three lines of Micra, the rest is the actual problem
The post editor is a regular form_with. The form element itself is the component — Rails’ data: option produces the attributes:
<%= form_with(model: [:dashboard, @note], local: true, class: "post-editor",
data: {
component: "autosave",
key: "notes:#{@note.persisted? ? @note.id : 'new'}",
server_updated: @note.updated_at&.to_i || 0
}) do |form| %>
<div class="autosave-banner" data-show="hasSnapshot()">
<span>Unsaved draft from <span data-text="snapshotTime()"></span></span>
<button type="button" @click="restore">Restore</button>
<button type="button" @click="discard">Discard</button>
</div>
<%# … title, content, the rest of the fields … %>
<button type="submit">Save</button>
<p class="autosave-status" data-show="status()" data-text="status()"></p>
<% end %>
server_updated becomes data-server-updated, which the component reads as this.prop("serverUpdated"). The key separates drafts: notes:new for a new post, notes:42 for editing post 42.
That banner and the status line are everything Micra does here. The rest of the component is plain JavaScript, and it’s where the actual thinking went — almost all of it about when not to save or restore.
Micra.define("autosave", {
state: { hasSnap: false, snapAt: 0, savedAt: 0 },
onCreate() {
this._key = `dashboard-autosave:${this.prop("key")}`
this._serverAt = Number(this.prop("serverUpdated") || 0)
this._form = this.$el
this._dirty = false
this._submitted = false
// offer a restore only if the snapshot is newer than the server copy
// and has at least one non-blank field
const raw = this._read()
if (raw && raw.at && raw.at > this._serverAt * 1000 && this._hasContent(raw.fields)) {
this.state.hasSnap = true
this.state.snapAt = raw.at
} else if (raw) {
this._clear()
}
this._onInput = () => { this._dirty = true }
this._form.addEventListener("input", this._onInput, true)
this._timer = setInterval(() => this._tickSave(), 10000)
// a submit means the data is on its way to the server:
// drop the snapshot and never write it back
this._onSubmit = () => this._markSubmitted()
this._onTurboSubmitEnd = e => {
if (e.detail?.success !== false) this._markSubmitted()
}
this._form.addEventListener("submit", this._onSubmit)
document.addEventListener("turbo:submit-end", this._onTurboSubmitEnd)
this._onBeforeUnload = () => {
if (this._dirty && !this._submitted) this._save()
}
window.addEventListener("beforeunload", this._onBeforeUnload)
},
onDestroy() {
clearInterval(this._timer)
this._form.removeEventListener("input", this._onInput, true)
this._form.removeEventListener("submit", this._onSubmit)
document.removeEventListener("turbo:submit-end", this._onTurboSubmitEnd)
window.removeEventListener("beforeunload", this._onBeforeUnload)
},
hasSnapshot() { return this.state.hasSnap },
snapshotTime() {
return this.state.snapAt ? new Date(this.state.snapAt).toLocaleString() : ""
},
status() {
if (!this.state.savedAt) return ""
const secs = Math.max(1, Math.round((Date.now() - this.state.savedAt) / 1000))
return secs < 60 ? `Saved locally ${secs}s ago` : `Saved locally ${Math.round(secs / 60)} min ago`
},
discard() {
this._clear()
this.state.hasSnap = false
},
// restore(), _save(), _read(), _clear(), _hasContent() — below
})
Each condition guards against a specific way of losing text. The comparison with serverUpdated exists because a draft from yesterday must not be offered on top of a post I’ve since saved from another device. The _hasContent check exists because a snapshot of an empty form is worse than none: restoring it wipes whatever I’ve typed since the page opened. And _submitted exists because the form stays on the page for a moment after it’s sent: a ten-second tick or a beforeunload landing in that window would dutifully save the form I had just submitted, and the next visit would offer to “restore” a post that was already saved. Turbo submits forms itself and reports the result in a turbo:submit-end event with success in its detail, so the component listens for that as well as for the plain submit.
Saving and restoring are plain DOM work over the form’s named fields:
_save() {
if (this._submitted) return
try {
const fields = {}
this._form.querySelectorAll("input[name], textarea[name], select[name]").forEach(el => {
if (["hidden", "submit", "button", "file"].includes(el.type)) return
fields[el.name] = el.type === "checkbox" ? el.checked : el.value
})
if (!this._hasContent(fields)) return this._clear()
const payload = { at: Date.now(), fields }
localStorage.setItem(this._key, JSON.stringify(payload))
this.state.savedAt = payload.at
} catch (_) { /* quota or private mode: skip */ }
},
restore() {
const data = this._read()
if (!data?.fields) return
Object.entries(data.fields).forEach(([name, value]) => {
const el = this._form.querySelector(`[name="${CSS.escape(name)}"]`)
if (!el) return
if (el.type === "checkbox") {
el.checked = value === true || value === "true" || value === "1"
} else {
// never replace a filled field with an empty saved value
if (!String(value ?? "").trim() && String(el.value ?? "").trim()) return
el.value = value
}
// let other components on the form (the counters) see the change
el.dispatchEvent(new Event("input", { bubbles: true }))
})
this.state.hasSnap = false
},
Why querySelectorAll and not data-model? Because the fields belong to Rails. form.text_field :title renders name="note[title]", the form submits the normal way, and validation errors re-render it on the server. Moving the values into component state would mean duplicating the form for the sake of a feature that only needs to read and write it now and then. The component reads the form; it doesn’t own it.
One thing didn’t come out right. status() is a method of state, so it’s recomputed when the component renders — that is, when state changes. Right after a save it says “Saved locally 1s ago” and keeps saying that until the next save ten seconds later. It’s not a clock. To make it tick I’d need a timer that writes to state once a second; for a hint at the bottom of an admin form that seemed like too much, so it stays as it is.
The character counter: a component that watches someone else’s field
The counter under the post body and the one under the meta description are the same component:
<%= form.text_area :meta_description, rows: 3, maxlength: 160 %>
<p class="char-counter"
data-component="char-counter"
data-target-id="note_meta_description"
data-max="160"
data-suffix="characters"
data-class="is-over:isOver()"
data-text="display()"></p>
The component is the <p> itself, and its directives sit on its own root. The textarea is not inside it — Rails generated it a few lines above with the id note_meta_description, and the counter finds it by that id:
Micra.define("char-counter", {
state: { length: 0, max: 0 },
onCreate() {
this._target = document.getElementById(this.prop("targetId"))
if (!this._target) return
this.state.max = Number(this.prop("max")) || 0
this.state.length = this._target.value.length
this._onInput = () => { this.state.length = this._target.value.length }
this._target.addEventListener("input", this._onInput)
},
onDestroy() {
this._target?.removeEventListener("input", this._onInput)
},
isOver() { return this.state.max > 0 && this.state.length > this.state.max },
display() {
const { length, max } = this.state
const suffix = this.prop("suffix", "")
const base = max > 0 ? `${length} / ${max}` : `${length}`
return suffix ? `${base} ${suffix}` : base
},
})
The listener goes on an element outside the component, so it’s added and removed by hand in onCreate/onDestroy. The alternative was to wrap the textarea and the counter in one component element; that would have meant restructuring the form’s markup for the sake of a counter, and I preferred to leave the form as Rails generates it. The two counters on the page are two independent instances: the post body has no limit and shows just the number, the description has a limit of 160 and switches to the danger color past it via is-over.
The input event that restore() dispatches is for these counters: after a draft is restored, they recount.
Tag autocomplete: focus, blur, and a dropdown
Tags are a single text field, comma-separated — that’s what the Note model accepts as all_tags. Over it sits a list of suggestions from a JSON endpoint:
<div class="tag-input"
data-component="tag-input"
data-suggest-url="<%= autocomplete_dashboard_tags_path %>">
<input type="text" name="note[all_tags]" value="<%= @note.all_tags %>"
autocomplete="off" data-ref="input"
@input="onInput" @keydown="onKeydown" @focus="onFocus" @blur="onBlur">
<ul class="tag-suggestions" data-show="showSuggestions()">
<template data-each="suggestions" data-key="name">
<li class="tag-suggestion"
data-class="is-active:item.name===activeName"
@mousedown.prevent="pick"
data-bind="data-name:item.name">
<span data-text="item.name"></span>
<span data-text="item.count"></span>
</li>
</template>
</ul>
</div>
State holds the suggestions, the highlighted name, and whether the field has focus. The list is visible when the field has focus and there’s something to show — showSuggestions() is a method, not a separate flag. The keyboard moves activeName through the list, and the highlight follows through data-class.
The interesting part is the interplay of focus and click. Clicking a suggestion takes focus away from the input, blur hides the list, and the click lands on an element that is no longer visible. Two things fix that together. The suggestions react to mousedown rather than click, with .prevent, so the input never loses focus. And onBlur hides the list with a short delay instead of immediately:
onFocus() {
clearTimeout(this._blurTimer)
this.state.focused = true
if (!this.state.suggestions.length) this._fetch("")
},
onBlur() {
this._blurTimer = setTimeout(() => { this.state.focused = false }, 150)
},
onInput(e) {
const term = this._currentTerm(e.target.value)
clearTimeout(this._debounceTimer)
this._debounceTimer = setTimeout(() => this._fetch(term), 120)
},
async _fetch(term) {
try {
const res = await fetch(`${this._suggestUrl}?q=${encodeURIComponent(term)}`,
{ headers: { Accept: "application/json" } })
if (!res.ok) return
const data = await res.json()
const existing = new Set(this._existingTags())
const filtered = data.filter(s => !existing.has(s.name))
this.state.suggestions = filtered
this.state.activeName = filtered[0]?.name || ""
} catch (_) {}
},
Tags already in the field are filtered out of the suggestions, and choosing one rewrites the last comma-separated segment of the input. Both timers are cleared in onDestroy. On the Rails side the endpoint is an ordinary action that returns [{ name, count }] as JSON.
How it divides up
Looking at the six components together, Micra’s share in each is modest, and I think that’s right. Rails renders the forms and validates them, Turbo carries navigation and submission, and Micra keeps the few bits of state that live only in the browser while I edit: is there a draft to offer, how many characters, which suggestion is highlighted, is the menu open. The markup says what depends on that state; the code around it does the work that would have been there anyway — reading the form, talking to localStorage, deciding when to save.
Stimulus is in the importmap too, as the Rails generator left it, with nothing but hello_controller.js. I never needed it in this project: none of these spots is a controller that finds an element, does something and forgets. Each of them holds a bit of state, and the page has to follow it.
The general recipe — importmap setup, passing data from ERB, Turbo Frames and Streams, and the optional micra-rails gem — is in the Rails recipe.