SSR

Micra works well with server-rendered HTML. Render markup on the server, embed props in data-* attributes, and call Micra.start() in the browser.

Basic SSR pattern

Server HTML:

<div
  data-component="users-page"
  data-page="2"
  data-per-page="25"
  data-filter="active"
>
  <h1>Users</h1>
  <p>Page <span data-text="page"></span></p>
</div>

Client code:

import * as Micra from 'micra.js'

Micra.define('users-page', {
  state: {
    page: 1,
    perPage: 10,
    filter: 'all',
  },

  onCreate() {
    this.state.page = this.prop('page', 1)
    this.state.perPage = this.prop('perPage', 10)
    this.state.filter = this.prop('filter', 'all')
  },
})

document.addEventListener('DOMContentLoaded', () => {
  Micra.start()
})

Props from data-* attributes

Use this.prop(name, defaultValue?) to read values from the root element.

<section data-component="report" data-page="3" data-show-chart="true"></section>
onCreate() {
  const page = this.prop('page', 1)          // 3
  const showChart = this.prop('showChart', false) // true
}

Auto-casting rules:

Use dataset-style names: data-per-page becomes this.prop('perPage').

Hydration pattern

Micra does not use a virtual DOM hydration step. Instead, it enhances existing HTML in place.

Common pattern:

  1. render the initial page on the server
  2. include data-component on interactive roots
  3. include initial props in data-* attributes
  4. call Micra.start() when the DOM is ready
  5. let Micra bind events, collect refs, and activate directives

This works well for Rails, Laravel, Phoenix, Django, and custom SSR setups.

Hydration contract

When mount() runs against server-rendered HTML, the rules are simple:

  1. The initial render is synchronous and uses the state literal. Before mount() returns, every directive has applied the values from definition.state exactly once.
  2. State is the source of truth. If the server-rendered DOM already matches the initial state, nothing changes — same textContent, same attribute values. If it differs, Micra overwrites it.
  3. onCreate runs in a microtask, AFTER the initial render. Any state mutation inside onCreate (including this.state.x = this.prop('x')) schedules a second render.

Concretely:

Directive SSR matches state SSR differs from state
data-text text preserved text overwritten
data-html HTML preserved HTML overwritten
data-bind attr preserved attr overwritten
data-class classes preserved toggled to match state
data-if element preserved detached if state is falsy
data-show display preserved style.display flipped

Two-stage flicker (and how to avoid it)

The canonical pattern in the SSR section above reads props in onCreate:

state: { page: 1, perPage: 10 },
onCreate() {
  this.state.page = this.prop('page', 1)
  this.state.perPage = this.prop('perPage', 10)
}

This is two-stage:

  1. Synchronous initial render uses { page: 1, perPage: 10 } — the literal defaults.
  2. Microtask: onCreate runs, copies data-page="2" into state.page, schedules a render.
  3. Microtask: render runs again with { page: 2, perPage: 25 }.

If the server also rendered <span data-text="page">2</span>, the text briefly flips to 1 and back to 2 — a one-microtask flicker that’s visible on slow devices.

The no-flicker pattern: seed the state literal directly from the server, so the synchronous initial render already matches the DOM:

<div data-component="users-page">
  <h1>Users</h1>
  <p>Page <span data-text="page">2</span></p>
</div>

<script>
  Micra.define('users-page', {
    state: {
      page:    2,           // ← inlined by the server template
      perPage: 25,
      filter:  'active',
    },
  })
  Micra.start()
</script>

Server template (ERB / Twig / Blade / Liquid / EEx):

state: { page: <%= @page %>, perPage: <%= @per_page %>, filter: <%= @filter.to_json %> },

Now there’s only one render, and it already agrees with the server’s HTML — no second pass, no flicker.

Use the data-* + onCreate pattern when:

What hydration does NOT do

Safe repeated starts

Micra.start() is idempotent for already-mounted roots.

Micra.start()
Micra.start()
Micra.start(document.getElementById('new-fragment')!)

Existing instances are skipped, so repeated calls are safe after partial page updates or HTML inserts.

Tearing down on swap

Mounting is only half the story on a server-driven page. When a swap (htmx, Turbo, Astro view transitions, or a raw innerHTML replace) removes a [data-component] element, its component must be torn down — otherwise the document/window listeners it added in onCreate (outside-click, global keydown) and any timers leak. Per-element @event listeners die with their nodes; global ones do not.

Two ways to handle it:

// Explicit — tear down a specific subtree you're about to replace.
Micra.destroy(document.getElementById('panel')!)
// Automatic — one call covers every swap mechanism, no per-framework wiring.
Micra.start()
Micra.autoCleanup()

autoCleanup() watches the DOM and runs destroy() whenever a mounted element leaves the document, by whatever removed it. Moved/reordered nodes (still connected) are left alone, so it never interferes with Micra’s own rendering. This is the backend-agnostic answer — it works the same whether the HTML came from Rails, Laravel, Django, or a Node server.