September 8, 2026
From jQuery to Micra: rewriting a typical page
I still run into jQuery sites regularly — not in archives, in paying work: a company site, a landing page from 2018, a client area nobody wants to touch. That’s not a complaint. jQuery earned its era honestly: back when browsers couldn’t even agree on how to attach an event handler, $ let you write one version of the code instead of three. A good share of those pages still work fine today.
Modernity alone is no reason to rewrite them. The interesting case is when you have to go back into that code: fix the menu, add a field to the form, squeeze in one more tab. Every time, I catch myself spending the effort on reconstruction — which classes mean what, who toggles them, and what is supposed to be visible when .nav has the open class but .burger somehow doesn’t.
Below is a typical chunk of such a page: a mobile menu, tabs, and a small request form. First the way it’s usually written in jQuery, then the same thing in Micra — a small reactivity library that sits on top of server-rendered markup. The line count comes out about even; the difference is where the state lives.
The starting point
The markup, abbreviated:
<header>
<button class="burger" aria-expanded="false"></button>
<nav class="nav">…</nav>
</header>
<div class="tabs">
<button class="active" data-tab="desc">Description</button>
<button data-tab="shipping">Shipping</button>
<button data-tab="reviews">Reviews</button>
</div>
<div id="desc" class="tab-pane">…</div>
<div id="shipping" class="tab-pane" style="display:none">…</div>
<div id="reviews" class="tab-pane" style="display:none">…</div>
<form id="callback">
<fieldset>
<input name="email" placeholder="Email">
<button type="submit">Send</button>
</fieldset>
<p class="error" style="display:none"></p>
<p class="thanks" style="display:none">Thanks — we'll be in touch.</p>
</form>
And the script:
$(function () {
// menu
$('.burger').on('click', function () {
var open = $('.nav').toggleClass('open').hasClass('open');
$(this).toggleClass('active').attr('aria-expanded', open);
});
// tabs
$('.tabs button').on('click', function () {
$(this).addClass('active').siblings().removeClass('active');
$('.tab-pane').hide();
$('#' + $(this).data('tab')).show();
});
// request form
$('#callback').on('submit', function (e) {
e.preventDefault();
var $form = $(this);
var email = $form.find('[name=email]').val().trim();
if (!email) {
$form.find('.error').text('Please enter your email').show();
return;
}
$form.find('.error').hide();
var $btn = $form.find('[type=submit]').prop('disabled', true).text('Sending…');
$.post('/api/callback', { email: email })
.done(function () {
$form.find('fieldset').hide();
$form.find('.thanks').show();
})
.fail(function () {
$form.find('.error').text('Something went wrong, please try again').show();
})
.always(function () {
$btn.prop('disabled', false).text('Send');
});
});
});
This is decent jQuery. Nothing is convoluted, every line reads fine — I wrote code like this for years, and I’m not going to pretend it’s terrible.
Where the state is
The page has state: is the menu open, which tab is active, is the form being sent, has it been sent already. But none of it is declared anywhere in the code. The menu being open is the open class on .nav plus a duplicated active on the button. The active tab is a class on one of the buttons and display on the panes — two records of the same fact, flipped by four operations. “Sending” is the button’s disabled property together with its label.
When the code needs to know the state, it reads it back out of the DOM — hasClass('open'). The DOM is both the presentation and the storage. With one handler, that works. It gets harder when a fact has several reflections: each one is flipped by its own line, and all the lines have to agree. Say the menu gains a backdrop overlay — now “open” has three reflections, and any new code that touches the menu (close on outside click, close on anchor navigation) has to remember all three.
This is a consequence of the tool, not of sloppiness. jQuery is a DOM manipulation tool, so state naturally settles in the DOM.
The same thing in Micra
One tag to include it:
<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>
Micra mounts onto markup you already have: you wrap a block in data-component and describe its behavior as an object with state and methods. Block by block, then.
The menu.
<header data-component="nav">
<button class="burger" @click="toggle"
data-class="active:open"
data-bind="aria-expanded:open ? 'true' : 'false'"></button>
<nav class="nav" data-class="open:open">…</nav>
</header>
Micra.define('nav', {
state: { open: false },
toggle() { this.state.open = !this.state.open },
})
The translation is nearly mechanical. $('.burger').on('click', …) became @click="toggle" right on the button, and both toggleClass calls became data-class declarations: the active class follows the open field, and so does the open class. The fact “the menu is open” is now written down once, in state.open. When the backdrop shows up, that’s one more data-class on the overlay element — not a line to add to every handler that touches the menu.
The tabs.
<div data-component="tabs">
<div class="tabs">
<button @click="select('desc')" data-class="active:tab === 'desc'">Description</button>
<button @click="select('shipping')" data-class="active:tab === 'shipping'">Shipping</button>
<button @click="select('reviews')" data-class="active:tab === 'reviews'">Reviews</button>
</div>
<div class="tab-pane" data-show="tab === 'desc'">…</div>
<div class="tab-pane" data-show="tab === 'shipping'">…</div>
<div class="tab-pane" data-show="tab === 'reviews'">…</div>
</div>
Micra.define('tabs', {
state: { tab: 'desc' },
select(tab) { this.state.tab = tab },
})
The addClass().siblings().removeClass() chain disappeared along with the question it was answering — how to take the class off the previous button. The active tab is a string, tab, and a string can’t hold two values at once. hide()/show() turned into data-show, which is the direct analog: the element stays in the DOM and only display toggles. (If a tab’s content should leave the DOM entirely, there’s data-if.)
The form.
<form data-component="callback" @submit.prevent="submit">
<fieldset data-show="!sent">
<input name="email" data-model="email" placeholder="Email">
<button data-bind="disabled:loading"
data-text="loading ? 'Sending…' : 'Send'"></button>
</fieldset>
<p class="error" data-if="error" data-text="error"></p>
<p class="thanks" data-if="sent">Thanks — we'll be in touch.</p>
</form>
Micra.define('callback', {
state: { email: '', loading: false, error: '', sent: false },
async submit() {
if (!this.state.email.trim()) {
this.state.error = 'Please enter your email'
return
}
this.state.loading = true
this.state.error = ''
try {
await this.fetch('/api/callback', { method: 'POST', body: { email: this.state.email } })
this.state.sent = true
} catch (e) {
this.state.error = 'Something went wrong, please try again'
} finally {
this.state.loading = false
}
},
})
Micra.start() // one call mounts all three components
This is where the translation shows best. .val() became data-model — the field’s value always lives in state.email, and there’s nothing to read out of the DOM. The prop('disabled') + text() pair became two declarations off a single loading field, and the .always() branch that restored the button isn’t needed: the moment loading goes back to false, the button restores itself. The .error.hide() line before a retry is gone too — the error’s visibility is declared as data-if="error", so clearing the field hides the message. $.post became this.fetch: it speaks JSON and throws on non-2xx, so failure lands in an ordinary catch.
What exactly went away
Put the two versions side by side and the volume is about the same. What went away is something else.
Addressing went away. In the jQuery version, nearly every line starts with a lookup: $form.find(…), $('#' + …), siblings(). That’s the brittle part — move .error outside the form during a markup change and find silently stops finding it. In the Micra version, elements declare their own dependency on state, right in the markup; the code never has to go looking for them.
And the source of drift went away. Each fact — “open”, “active”, “sending” — is recorded in one place, and all of its DOM reflections are expressed as dependencies. A new reflection is one more data-* attribute on an element, not a line to duplicate in every place the fact changes.
A separate note on migration: it isn’t all-or-nothing. Micra only mounts components where data-component is present, and it coexists peacefully with jQuery on the same page. The realistic path is block by block — the form today, the menu on the next visit. Code nobody touches can stay on jQuery indefinitely.
Where jQuery is still fine
The honest answer: lots of places.
If the site works and isn’t being changed, a rewrite buys you nothing but risk — code doesn’t rot from age alone. If plugins depend on jQuery — a datepicker, a gallery, an old payment widget — they are what’s keeping it on the page, and removing $ for the sake of removing it won’t pay off. If the entire interactivity is a couple of handlers nobody has visited in years, let them be.
The code worth rewriting is the code you keep coming back to: where there are now several pieces of state, they’ve started to overlap, and every fix begins with an excavation — which classes mean what and who toggles them. That’s exactly when moving the truth out of classes into one object pays for itself quickly.
The full example code, along with a few ready-made components, is at micrajs.dev. And the jQuery on pages nobody visits can be left alone with a clear conscience: it’s doing its job.