A few weeks ago, I was working on a side project. Nothing fancy — just a small dashboard with a form, a list, and a few buttons. The kind of thing that should take an afternoon.
But I caught myself opening npm install react react-dom react-router-dom. Then Vite. Then TypeScript config. Then a state management library. Then a build pipeline.
For a form.
I closed the terminal. Something felt wrong. I've been writing JavaScript for years — I know how to do this without a framework. But every time I reach for vanilla, I end up with the same routine:
const el = document.createElement('li');
el.textContent = todo.text;
el.className = todo.done ? 'done' : '';
document.getElementById('list').appendChild(el);
It works. And some friends of mine, still using JQuery instead.
The Experiment
I started asking a simple question:
What if I could parse HTML once, register every dynamic part, and then update only the pieces that change?
No virtual DOM. No diffing. No compiler. Just the browser's own tools.
Three APIs came to mind:
- DOMParser — the browser can already parse HTML into a DOM tree. I don't need a template compiler like htm.
- Proxy — JavaScript's native reactive primitive. Every property change can be intercepted.
- Child index paths — if I know the path to a node ([5, 0, 0]), I can update it directly.
So I wrote a prototype. It was rough — about 200 lines. But it worked:
HTML string → parsed into a DOM tree.
Every {{ }}, :for, @click, :class → registered with a path.
State changes → only the affected bindings update.
No diffing. No re-render. Just surgical DOM updates.
I called it HTMP— HyperText Mutation & Projection.
What I Ended Up With
Two weeks later, after some iteration, I had this:
import { HTMP } from 'https://unpkg.com/htm-projection@latest/dist/esm/index.js';
const pattern = `
<div>
<h1>{{ title }}</h1>
<input type="text" @input="changeTitle(e)" placeholder="Type title..." />
<p>Count: {{ count }}</p>
<button @click="increment()">Increment</button>
<ul>
<li :for="todo in todos">
<span :class="todo.done ? 'done' : ''">{{ todo.text }}</span>
<button @click="finishTodo(todo)">Finish</button>
<button @click="deleteTodo(todo)">Delete</button>
</li>
</ul>
</div>
`;
const app = new HTMP('app', pattern);
app.setProxy({
title: '',
count: 0,
todos: []
});
app.setProgram({
changeTitle: (e) => { app.proxy.title = e.target.value; },
increment: () => { app.proxy.count++; },
finishTodo: (todo) => {
app.proxy.todos = app.proxy.todos.map(t =>
t.id === todo.id ? { ...t, done: !t.done } : t
);
},
deleteTodo: (todo) => {
app.proxy.todos = app.proxy.todos.filter(t => t.id !== todo.id);
}
});
app.mount();
That's it. No JSX. No hooks. No build step. No npm install.
It weighed 2.9 kB gzipped.
Try It Right Now (30 Seconds)
I want you to try this. Not because I'm selling anything — just because I want to see if it works for someone else.
Step 1: Create a file called test.html on your desktop.
Step 2: Paste this into it:
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; padding: 20px; }
.done { text-decoration: line-through; color: gray; }
button { cursor: pointer; margin: 2px; }
</style>
</head>
<body>
<button onclick="app.unmount()">Unmount</button>
<button onclick="app.remount()">Remount</button>
<div id="app"></div>
<script type="module">
import { HTMP } from 'https://unpkg.com/htm-projection@latest/dist/esm/index.js';
const pattern = `
<div>
<h1>{{ title }}</h1>
<input type="text" @input="changeTitle(e)" placeholder="Type title..." />
<p>Count: {{ count }}</p>
<button @click="increment()">Increment</button>
<hr>
<p><i>{{ todos.length === 0 ? 'No todos yet.' : '' }}</i></p>
<input type="text" @input="changeInput(e)" :value="inputTodo" placeholder="New task..." />
<button @click="addTodo()">Add Todo</button>
<ul>
<li :for="todo in todos">
<span :class="todo.done ? 'done' : ''">{{ todo.text }}</span>
<button @click="finishTodo(todo)">{{ todo.done ? 'Undo' : 'Finish' }}</button>
<button @click="deleteTodo(todo)">Delete</button>
</li>
</ul>
</div>
`;
const app = new HTMP('app', pattern);
app.setProxy({
title: '',
count: 0,
inputTodo: '',
todos: []
});
app.setProgram({
changeTitle: (e) => { app.proxy.title = e.target.value; },
increment: () => { app.proxy.count++; },
changeInput: (e) => { app.proxy.inputTodo = e.target.value; },
addTodo: () => {
if (!app.proxy.inputTodo.trim()) return;
app.proxy.todos = [...app.proxy.todos, { id: Date.now(), text: app.proxy.inputTodo, done: false }];
app.proxy.inputTodo = '';
},
finishTodo: (todo) => {
app.proxy.todos = app.proxy.todos.map(t => t.id === todo.id ? { ...t, done: !t.done } : t);
},
deleteTodo: (todo) => {
app.proxy.todos = app.proxy.todos.filter(t => t.id !== todo.id);
}
});
app.mount();
window.app = app;
</script>
</body>
</html>
Step 3: Save it. Double-click the file, now its run in your browser.
Step 4: Type a title. Increment the counter. Add a todo. Finish it. Delete it.
Step 5: Click "Unmount" — the app disappears. Click "Remount" — it comes back with state intact.
No server. No npm. No build. Just a static HTML file running a reactive app.
If it works, you'll understand in 30 seconds why I got excited.
If it doesn't work, tell me — I want to know.
What I Learned Along the Way
This experiment taught me a few things:
The browser already has everything.
DOMParser parses HTML. Proxy handles reactivity. addEventListener handles events. I didn't need to invent anything — just connect what was already there.Less code means fewer bugs.
The entire engine is a few hundred lines. No virtual DOM, no diffing algorithm, no compiler. Less surface area for bugs.Constraint drives creativity.
I forced myself to use only browser APIs. No dependencies. That constraint led to a design that's smaller than HTMX (which is server-rendered) and smaller than snabbdom (which is a VDOM library).It just works — even for AI agents.
I asked a coding agent with zero knowledge of HTMP to learn README.md and API.md, then build an SPA with routing implements only what browser already have: history API. It did — no hallucination, no debugging. Because the pattern is HTML, and agents already know HTML.
What This Isn't
I want to be honest. HTMP isn't for everything.
Not a React replacement for large applications with complex state. But I tried it.
Not a Vue replacement for teams already invested in the ecosystem.
Not a HTMX replacement for server-rendered content sites.
It's for the in-between:
A form that needs reactivity usually use JQuery and some library like notification library, but doesn't need React.
A widget embedded in a PHP or WordPress page.
A small SPA that needs routing but not a framework.
A legacy project where npm install isn't an option.
Where It Stands
HTMP is at v1.0.1. as this article wrote. It has:
4 live examples (basic, form, SPA, note-guard)
Smoke tests
Zero dependencies
2.9 kB gzipped
Verified by BundlePhobia
Works in any browser
I don't know if anyone else will find it useful. But I built it for myself, and it solved my problem. If it solves yours too — great.
If you tried the test.html and it worked, I'd love to hear what you think.
Links
GitHub: github.com/erlanggasatria-source/HTMP
npm: npmjs.com/package/htm-projection
I'm not a framework author. I'm just a developer who got tired of VDOM and decided to try something simpler. If you're curious — copy the code, save it as test.html, and open it. That's the whole point.
Top comments (0)