June 26, 2026
Reactivity instead of updating the DOM by hand: the same tasks in plain JS and in Micra
In plain JavaScript you do interactivity by hand: find an element and change it — toggle a class, set an attribute, write some text. For a single button, that’s exactly right.
What makes this kind of code heavy is state. “Is the menu open,” “what’s in the filter right now,” “which tasks are checked” — none of it is written down anywhere explicit. It’s spread across classes, attributes, and variables in closures, and you keep the DOM in line with it by hand. The more of these little states you have, and the more they overlap, the more watching you do — and the easier it is to let something fall out of sync.
Reactivity flips the direction. You don’t push changes into the DOM step by step — you declare what depends on what, and you touch only the state. The DOM follows on its own. Micra is a small library that does exactly this, on top of HTML the server already rendered. It doesn’t replace anything and doesn’t claim to; it fills the niche where the server has handed you markup and the client just needs a bit of interactivity.
Below are a few identical tasks, each done twice: in plain JS and in Micra. There’s a line-count difference, but the more interesting thing is the point where tending the DOM by hand stops paying off.
The script, so the examples run:
<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>
A toggle: nothing to change here
Open and close a menu. One bit of state, read in one place.
const btn = document.querySelector(".burger");
const menu = document.querySelector(".menu");
btn.addEventListener("click", () => menu.classList.toggle("open"));
Three lines. In Micra you’d have to define a component with an open field and a method — longer, for no gain. If the page has exactly one such switch, it doesn’t need reactivity.
But the front end never leaves anything simple for long. So let’s look at the case where the same state is read in more than one place.
One piece of state, several reflections
“Show more”: a click flips the block’s visibility, the button’s text, and aria-expanded.
const btn = document.querySelector(".more-btn");
const extra = document.querySelector(".extra");
let expanded = false;
btn.addEventListener("click", () => {
expanded = !expanded;
extra.hidden = !expanded;
btn.textContent = expanded ? "Show less" : "Show more";
btn.setAttribute("aria-expanded", String(expanded));
});
The imperative shape is clear here: on every click you spell out what to change in the DOM. Three manual updates, and expanded has to match all three. Want to rotate an icon too? That’s a fourth line right here, and it’s easy to forget.
The reactive view turns the question around: instead of “what to change on click,” it’s “what depends on what.”
<div data-component="more">
<div class="extra" data-show="expanded"><!-- … --></div>
<button
@click="toggle"
data-text="expanded ? 'Show less' : 'Show more'"
data-bind="aria-expanded:expanded ? 'true' : 'false'"
></button>
</div>
<script>
Micra.define("more", {
state: { expanded: false },
toggle() {
this.state.expanded = !this.state.expanded;
},
});
Micra.start();
</script>
Visibility, text, and the attribute are declared as functions of expanded. The toggle method flips one bit; Micra recomputes the rest — you never touch the DOM. A new reflection of the state is one more data-* next to the others, not a line in a handler you have to remember to keep in sync.
Many identical blocks: a FAQ
Now not one block but a dozen — an ordinary FAQ where each question expands. In vanilla you usually loop over the blocks and attach a handler to each:
document.querySelectorAll(".faq").forEach((faq) => {
const btn = faq.querySelector("button");
const panel = faq.querySelector(".answer");
btn.addEventListener("click", () => {
const willOpen = panel.hidden;
panel.hidden = !willOpen;
btn.setAttribute("aria-expanded", String(willOpen));
});
});
Not much either. But the “is this block open” state lives in the DOM again — you read it back out of panel.hidden. The DOM has become the source of truth, and the moment something else inside a block starts depending on “open,” you’re back to the hand-syncing from the previous example, only now inside a loop over every block.
In Micra the component is described once and appears on the page as many times as you like:
<div data-component="collapse" class="faq">
<button @click="toggle" data-bind="aria-expanded:open ? 'true' : 'false'">
How much?
</button>
<div class="answer" data-show="open">From $2,000.</div>
</div>
<div data-component="collapse" class="faq">
<button @click="toggle" data-bind="aria-expanded:open ? 'true' : 'false'">
How long?
</button>
<div class="answer" data-show="open">Two to three weeks.</div>
</div>
<!-- …as many blocks as you need… -->
<script>
Micra.define("collapse", {
state: { open: false },
toggle() {
this.state.open = !this.state.open;
},
});
Micra.start();
</script>
Each element with data-component="collapse" is its own instance with its own open, and Micra.start() brings them all up at once. Ten blocks become ten copies of markup (which the server usually generates in a loop anyway) and the same single define. No shared handler, no registry of “who’s open right now”: each block’s state is its own and lives in it, not in the DOM.
A separate case is a true accordion, where only one block may be open. That’s shared state between blocks, and it surfaces the same way in both approaches: in vanilla you add “close the others,” in Micra you lift the state into a parent component that holds openId. The moment “the state became shared, so it needs one owner” is universal, and it’s worth spotting early.
A list from data
A search over a list of services.
const items = [
{ id: 1, name: "Design" },
{ id: 2, name: "Development" } /* … */,
];
const input = document.querySelector("#q");
const ul = document.querySelector("#list");
function render() {
const q = input.value.trim().toLowerCase();
ul.innerHTML = items
.filter((i) => i.name.toLowerCase().includes(q))
.map((i) => `<li>${i.name}</li>`)
.join("");
}
input.addEventListener("input", render);
render();
This is a different shape: the HTML is built as a string and dropped in whole. That brings three familiar problems: ${i.name} in innerHTML is an XSS hole if the names don’t come from your own code; rebuilding on every keystroke drops focus and the state of anything that ends up inside a <li>; and doing it carefully — escaping, updating in place — is no longer five lines.
Reactively, the list is described as a function of the data:
<div data-component="services">
<input data-model="q" placeholder="Search services" />
<p data-text="summary()"></p>
<ul>
<template data-each="visible()" data-key="id">
<li data-text="item.name"></li>
</template>
</ul>
</div>
<script>
Micra.define("services", {
state: {
q: "",
items: [
{ id: 1, name: "Design" },
{ id: 2, name: "Development" } /* … */,
],
},
visible() {
const q = this.state.q.trim().toLowerCase();
return this.state.items.filter((i) => i.name.toLowerCase().includes(q));
},
summary() {
return `Found: ${this.visible().length}`;
},
});
Micra.start();
</script>
data-text writes text, not HTML, so escaping is the default and that XSS hole simply isn’t there. data-each with data-key diffs the list by key and touches only the rows that changed — focus and state inside a <li> survive the filtering. visible() and summary() are methods: derived values in Micra aren’t kept in state, they’re computed from it, so the filter and the counter can’t drift apart from the list.
If you do need HTML — say a name contains a marked-up fragment — there’s data-html for that. By default it writes the value as-is, but sanitizing turns on in one line:
import DOMPurify from "dompurify";
Micra.config({ sanitize: DOMPurify.sanitize });
After that every data-html value runs through the cleaner before it reaches the DOM. Micra doesn’t bundle the sanitizer itself — its weight and choice stay with you.
When the parts pile up: vanilla grows into a framework
Let’s build the thing people actually decide on: tasks with adding, checking off, deleting, a counter, and saving. Several event sources, shared state, a list from data. Reasonable vanilla code reaches roughly this shape by now:
let tasks = load(); // from localStorage
const root = document.querySelector("#app");
function render() {
root.querySelector(".list").innerHTML = tasks
.map(
(t) => `
<li data-id="${t.id}" class="${t.done ? "done" : ""}">
<button class="toggle">${escapeHtml(t.text)}</button>
<button class="del">×</button>
</li>`,
)
.join("");
root.querySelector(".count").textContent =
`${tasks.filter((t) => t.done).length} of ${tasks.length}`;
}
root.querySelector(".list").addEventListener("click", (e) => {
const li = e.target.closest("li");
if (!li) return;
const id = Number(li.dataset.id);
if (e.target.matches(".del")) tasks = tasks.filter((t) => t.id !== id);
else if (e.target.matches(".toggle"))
tasks = tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
save(tasks);
render();
});
render();
This isn’t bad code. But look at what it settled into. To keep nothing out of sync, the state was pulled out into tasks, and the DOM is built by a single render() — a single source of truth, plus a re-render. To avoid a handler on every button, one listener on the container with e.target dispatch — delegation. So ${t.text} doesn’t become a hole — escapeHtml. It’s a small home-grown framework: state, render, delegation, escaping. Only less battle-tested, and yours to maintain now.
And it gets predictably heavier. render() through innerHTML rebuilds the whole list; add an input inside a row and it’ll lose focus, so you’ll update in place or diff old against new. That’s reactivity, written out by hand.
The same tasks in Micra are the same structure, but the render and the delegation are no longer yours:
<div data-component="tasks">
<form @submit.prevent="add">
<input data-model="draft" placeholder="New task" />
</form>
<p data-text="summary()"></p>
<ul>
<template data-each="tasks" data-key="id">
<li data-class="done:item.done">
<button
class="toggle"
@click="toggle"
data-bind="data-id:item.id"
data-text="item.text"
></button>
<button class="del" @click="remove" data-bind="data-id:item.id">
×
</button>
</li>
</template>
</ul>
</div>
<script>
Micra.define("tasks", {
state: { draft: "", tasks: load() },
summary() {
const done = this.state.tasks.filter((t) => t.done).length;
return `${done} of ${this.state.tasks.length}`;
},
add() {
const text = this.state.draft.trim();
if (!text) return;
this.state.tasks = [
...this.state.tasks,
{ id: Date.now(), text, done: false },
];
this.state.draft = "";
save(this.state.tasks);
},
toggle(e) {
const id = Number(e.currentTarget.dataset.id);
this.state.tasks = this.state.tasks.map((t) =>
t.id === id ? { ...t, done: !t.done } : t,
);
save(this.state.tasks);
},
remove(e) {
const id = Number(e.currentTarget.dataset.id);
this.state.tasks = this.state.tasks.filter((t) => t.id !== id);
save(this.state.tasks);
},
});
Micra.start();
</script>
There’s no render() — what to show is described in the markup and rebuilds itself when state changes. There’s no delegation — Micra attaches and removes @click for you, and the id comes from data-id. Escaping comes from data-text. Updates are replacing tasks wholesale (.map, .filter, spread), not editing fields in place; data-key makes sure only the rows that changed re-render. I tried hard to keep any sense of magic out of this: it’s the same state, render, and delegation, just lifted out of your project into a library and done once.
Where Micra hits its ceiling
Reactivity has a limit too, and it’s not far past this example. When a page grows into an application — client-side routing, many screens, state shared between them, optimistic updates — Micra becomes too small: its reactivity is shallow and it has no router. That’s where React, Vue, or Svelte fit, and dropping a tiny library in there is as odd as pulling in a full framework for the toggle from the first example.
Put bluntly: one isolated piece of interactivity — plain JS; several of them whose state overlaps, plus lists from data — that’s the point where you’d write state and render() anyway, so it’s easier to take something ready-made; a full application — a big framework.
The example code and a few more ready-made components are at micrajs.dev.