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:

DirectiveSSR matches stateSSR differs from state
data-texttext preservedtext overwritten
data-htmlHTML preservedHTML overwritten
data-bindattr preservedattr overwritten
data-classclasses preservedtoggled to match state
data-ifelement preserveddetached if state is falsy
data-showdisplay preservedstyle.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.