{"id":5193,"date":"2026-09-14T01:30:22","date_gmt":"2026-09-14T04:30:22","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/09\/14\/how-i-ended-up-with-a-2-9-kb-gzip-reactive-ui-engine-that-runs-from-a-static-file\/"},"modified":"2026-09-14T01:30:22","modified_gmt":"2026-09-14T04:30:22","slug":"how-i-ended-up-with-a-2-9-kb-gzip-reactive-ui-engine-that-runs-from-a-static-file","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/09\/14\/how-i-ended-up-with-a-2-9-kb-gzip-reactive-ui-engine-that-runs-from-a-static-file\/","title":{"rendered":"How I ended up with a 2.9 kB GZip reactive UI engine that runs from a static file"},"content":{"rendered":"<div>\n<div><\/div>\n<p>It works. And some friends of mine, still using JQuery instead.<\/p>\n<h2> <a name=\"the-experiment\" href=\"#the-experiment\"> <\/a> The Experiment <\/h2>\n<p>I started asking a simple question:<\/p>\n<p>What if I could parse HTML once, register every dynamic part, and then update only the pieces that change?<\/p>\n<p>No virtual DOM. No diffing. No compiler. Just the browser&#8217;s own tools.<\/p>\n<h3> <a name=\"three-apis-came-to-mind\" href=\"#three-apis-came-to-mind\"> <\/a> Three APIs came to mind: <\/h3>\n<ol>\n<li> <strong>DOMParser<\/strong> \u2014 the browser can already parse HTML into a DOM tree. I don&#8217;t need a template compiler like <em>htm<\/em>.<\/li>\n<li> <strong>Proxy<\/strong> \u2014 JavaScript&#8217;s native reactive primitive. Every property change can be intercepted.<\/li>\n<li> <strong>Child index paths<\/strong> \u2014 if I know the path to a node ([5, 0, 0]), I can update it directly.<\/li>\n<\/ol>\n<p>So I wrote a prototype. It was rough \u2014 about 200 lines. But it worked:<\/p>\n<div>\n<pre><code>HTML string \u2192 parsed into a DOM tree. Every {{ }}, :for, @click, :class \u2192 registered with a path. State changes \u2192 only the affected bindings update. <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>No diffing. No re-render. Just surgical DOM updates.<\/p>\n<p>I called it <strong>HTMP<\/strong><em>\u2014 HyperText Mutation &amp; Projection<\/em>.<\/p>\n<h3> <a name=\"what-i-ended-up-with\" href=\"#what-i-ended-up-with\"> <\/a> What I Ended Up With <\/h3>\n<p>Two weeks later, after some iteration, I had this: <\/p>\n<div>\n<pre><code><span>import<\/span> <span>{<\/span> <span>HTMP<\/span> <span>}<\/span> <span>from<\/span> <span>'<\/span><span>https:\/\/unpkg.com\/htm-projection@latest\/dist\/esm\/index.js<\/span><span>'<\/span><span>;<\/span> <span>const<\/span> <span>pattern<\/span> <span>=<\/span> <span>` &lt;div&gt; &lt;h1&gt;{{ title }}&lt;\/h1&gt; &lt;input type=\"text\" @input=\"changeTitle(e)\" placeholder=\"Type title...\" \/&gt; &lt;p&gt;Count: {{ count }}&lt;\/p&gt; &lt;button @click=\"increment()\"&gt;Increment&lt;\/button&gt; &lt;ul&gt; &lt;li :for=\"todo in todos\"&gt; &lt;span :class=\"todo.done ? 'done' : ''\"&gt;{{ todo.text }}&lt;\/span&gt; &lt;button @click=\"finishTodo(todo)\"&gt;Finish&lt;\/button&gt; &lt;button @click=\"deleteTodo(todo)\"&gt;Delete&lt;\/button&gt; &lt;\/li&gt; &lt;\/ul&gt; &lt;\/div&gt; `<\/span><span>;<\/span> <span>const<\/span> <span>app<\/span> <span>=<\/span> <span>new<\/span> <span>HTMP<\/span><span>(<\/span><span>'<\/span><span>app<\/span><span>'<\/span><span>,<\/span> <span>pattern<\/span><span>);<\/span> <span>app<\/span><span>.<\/span><span>setProxy<\/span><span>({<\/span> <span>title<\/span><span>:<\/span> <span>''<\/span><span>,<\/span> <span>count<\/span><span>:<\/span> <span>0<\/span><span>,<\/span> <span>todos<\/span><span>:<\/span> <span>[]<\/span> <span>});<\/span> <span>app<\/span><span>.<\/span><span>setProgram<\/span><span>({<\/span> <span>changeTitle<\/span><span>:<\/span> <span>(<\/span><span>e<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>title<\/span> <span>=<\/span> <span>e<\/span><span>.<\/span><span>target<\/span><span>.<\/span><span>value<\/span><span>;<\/span> <span>},<\/span> <span>increment<\/span><span>:<\/span> <span>()<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>count<\/span><span>++<\/span><span>;<\/span> <span>},<\/span> <span>finishTodo<\/span><span>:<\/span> <span>(<\/span><span>todo<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span> <span>=<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span><span>.<\/span><span>map<\/span><span>(<\/span><span>t<\/span> <span>=&gt;<\/span> <span>t<\/span><span>.<\/span><span>id<\/span> <span>===<\/span> <span>todo<\/span><span>.<\/span><span>id<\/span> <span>?<\/span> <span>{<\/span> <span>...<\/span><span>t<\/span><span>,<\/span> <span>done<\/span><span>:<\/span> <span>!<\/span><span>t<\/span><span>.<\/span><span>done<\/span> <span>}<\/span> <span>:<\/span> <span>t<\/span> <span>);<\/span> <span>},<\/span> <span>deleteTodo<\/span><span>:<\/span> <span>(<\/span><span>todo<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span> <span>=<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span><span>.<\/span><span>filter<\/span><span>(<\/span><span>t<\/span> <span>=&gt;<\/span> <span>t<\/span><span>.<\/span><span>id<\/span> <span>!==<\/span> <span>todo<\/span><span>.<\/span><span>id<\/span><span>);<\/span> <span>}<\/span> <span>});<\/span> <span>app<\/span><span>.<\/span><span>mount<\/span><span>();<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>That&#8217;s it. No JSX. No hooks. No build step. No npm install.<\/p>\n<p>It weighed 2.9 kB gzipped.<\/p>\n<h2> <a name=\"try-it-right-now-30-seconds\" href=\"#try-it-right-now-30-seconds\"> <\/a> Try It Right Now (30 Seconds) <\/h2>\n<p>I want you to try this. <strong>Not because I&#8217;m selling anything<\/strong> \u2014 just because I want to see <strong>if it works for someone else<\/strong>.<\/p>\n<p><strong>Step 1<\/strong>: Create a file called test.html on your desktop.<\/p>\n<p><strong>Step 2<\/strong>: Paste this into it: <\/p>\n<div>\n<pre><code> <span>&lt;!DOCTYPE html&gt;<\/span> <span>&lt;html&gt;<\/span> <span>&lt;head&gt;<\/span> <span>&lt;style&gt;<\/span> <span>body<\/span> <span>{<\/span> <span>font-family<\/span><span>:<\/span> <span>sans-serif<\/span><span>;<\/span> <span>padding<\/span><span>:<\/span> <span>20px<\/span><span>;<\/span> <span>}<\/span> <span>.done<\/span> <span>{<\/span> <span>text-decoration<\/span><span>:<\/span> <span>line-through<\/span><span>;<\/span> <span>color<\/span><span>:<\/span> <span>gray<\/span><span>;<\/span> <span>}<\/span> <span>button<\/span> <span>{<\/span> <span>cursor<\/span><span>:<\/span> <span>pointer<\/span><span>;<\/span> <span>margin<\/span><span>:<\/span> <span>2px<\/span><span>;<\/span> <span>}<\/span> <span>&lt;\/style&gt;<\/span> <span>&lt;\/head&gt;<\/span> <span>&lt;body&gt;<\/span> <span>&lt;button<\/span> <span>onclick=<\/span><span>\"app.unmount()\"<\/span><span>&gt;<\/span>Unmount<span>&lt;\/button&gt;<\/span> <span>&lt;button<\/span> <span>onclick=<\/span><span>\"app.remount()\"<\/span><span>&gt;<\/span>Remount<span>&lt;\/button&gt;<\/span> <span>&lt;div<\/span> <span>id=<\/span><span>\"app\"<\/span><span>&gt;&lt;\/div&gt;<\/span> <span>&lt;script <\/span><span>type=<\/span><span>\"module\"<\/span><span>&gt;<\/span> <span>import<\/span> <span>{<\/span> <span>HTMP<\/span> <span>}<\/span> <span>from<\/span> <span>'<\/span><span>https:\/\/unpkg.com\/htm-projection@latest\/dist\/esm\/index.js<\/span><span>'<\/span><span>;<\/span> <span>const<\/span> <span>pattern<\/span> <span>=<\/span> <span>` &lt;div&gt; &lt;h1&gt;{{ title }}&lt;\/h1&gt; &lt;input type=\"text\" @input=\"changeTitle(e)\" placeholder=\"Type title...\" \/&gt; &lt;p&gt;Count: {{ count }}&lt;\/p&gt; &lt;button @click=\"increment()\"&gt;Increment&lt;\/button&gt; &lt;hr&gt; &lt;p&gt;&lt;i&gt;{{ todos.length === 0 ? 'No todos yet.' : '' }}&lt;\/i&gt;&lt;\/p&gt; &lt;input type=\"text\" @input=\"changeInput(e)\" :value=\"inputTodo\" placeholder=\"New task...\" \/&gt; &lt;button @click=\"addTodo()\"&gt;Add Todo&lt;\/button&gt; &lt;ul&gt; &lt;li :for=\"todo in todos\"&gt; &lt;span :class=\"todo.done ? 'done' : ''\"&gt;{{ todo.text }}&lt;\/span&gt; &lt;button @click=\"finishTodo(todo)\"&gt;{{ todo.done ? 'Undo' : 'Finish' }}&lt;\/button&gt; &lt;button @click=\"deleteTodo(todo)\"&gt;Delete&lt;\/button&gt; &lt;\/li&gt; &lt;\/ul&gt; &lt;\/div&gt; `<\/span><span>;<\/span> <span>const<\/span> <span>app<\/span> <span>=<\/span> <span>new<\/span> <span>HTMP<\/span><span>(<\/span><span>'<\/span><span>app<\/span><span>'<\/span><span>,<\/span> <span>pattern<\/span><span>);<\/span> <span>app<\/span><span>.<\/span><span>setProxy<\/span><span>({<\/span> <span>title<\/span><span>:<\/span> <span>''<\/span><span>,<\/span> <span>count<\/span><span>:<\/span> <span>0<\/span><span>,<\/span> <span>inputTodo<\/span><span>:<\/span> <span>''<\/span><span>,<\/span> <span>todos<\/span><span>:<\/span> <span>[]<\/span> <span>});<\/span> <span>app<\/span><span>.<\/span><span>setProgram<\/span><span>({<\/span> <span>changeTitle<\/span><span>:<\/span> <span>(<\/span><span>e<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>title<\/span> <span>=<\/span> <span>e<\/span><span>.<\/span><span>target<\/span><span>.<\/span><span>value<\/span><span>;<\/span> <span>},<\/span> <span>increment<\/span><span>:<\/span> <span>()<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>count<\/span><span>++<\/span><span>;<\/span> <span>},<\/span> <span>changeInput<\/span><span>:<\/span> <span>(<\/span><span>e<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>inputTodo<\/span> <span>=<\/span> <span>e<\/span><span>.<\/span><span>target<\/span><span>.<\/span><span>value<\/span><span>;<\/span> <span>},<\/span> <span>addTodo<\/span><span>:<\/span> <span>()<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>if <\/span><span>(<\/span><span>!<\/span><span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>inputTodo<\/span><span>.<\/span><span>trim<\/span><span>())<\/span> <span>return<\/span><span>;<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span> <span>=<\/span> <span>[...<\/span><span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span><span>,<\/span> <span>{<\/span> <span>id<\/span><span>:<\/span> <span>Date<\/span><span>.<\/span><span>now<\/span><span>(),<\/span> <span>text<\/span><span>:<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>inputTodo<\/span><span>,<\/span> <span>done<\/span><span>:<\/span> <span>false<\/span> <span>}];<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>inputTodo<\/span> <span>=<\/span> <span>''<\/span><span>;<\/span> <span>},<\/span> <span>finishTodo<\/span><span>:<\/span> <span>(<\/span><span>todo<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span> <span>=<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span><span>.<\/span><span>map<\/span><span>(<\/span><span>t<\/span> <span>=&gt;<\/span> <span>t<\/span><span>.<\/span><span>id<\/span> <span>===<\/span> <span>todo<\/span><span>.<\/span><span>id<\/span> <span>?<\/span> <span>{<\/span> <span>...<\/span><span>t<\/span><span>,<\/span> <span>done<\/span><span>:<\/span> <span>!<\/span><span>t<\/span><span>.<\/span><span>done<\/span> <span>}<\/span> <span>:<\/span> <span>t<\/span><span>);<\/span> <span>},<\/span> <span>deleteTodo<\/span><span>:<\/span> <span>(<\/span><span>todo<\/span><span>)<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span> <span>=<\/span> <span>app<\/span><span>.<\/span><span>proxy<\/span><span>.<\/span><span>todos<\/span><span>.<\/span><span>filter<\/span><span>(<\/span><span>t<\/span> <span>=&gt;<\/span> <span>t<\/span><span>.<\/span><span>id<\/span> <span>!==<\/span> <span>todo<\/span><span>.<\/span><span>id<\/span><span>);<\/span> <span>}<\/span> <span>});<\/span> <span>app<\/span><span>.<\/span><span>mount<\/span><span>();<\/span> <span>window<\/span><span>.<\/span><span>app<\/span> <span>=<\/span> <span>app<\/span><span>;<\/span> <span>&lt;\/script&gt;<\/span> <span>&lt;\/body&gt;<\/span> <span>&lt;\/html&gt;<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p><strong>Step 3<\/strong>: Save it. Double-click the file, now its run in your browser.<\/p>\n<p><strong>Step 4<\/strong>: Type a title. Increment the counter. Add a todo. Finish it. Delete it.<\/p>\n<p><strong>Step 5<\/strong>: Click &#8220;Unmount&#8221; \u2014 the app disappears. Click &#8220;Remount&#8221; \u2014 it comes back with state intact.<\/p>\n<p>No server. No npm. No build. Just a static HTML file running a reactive app.<\/p>\n<p>If it works, you&#8217;ll understand in 30 seconds why I got excited.<\/p>\n<p>If it doesn&#8217;t work, tell me \u2014 I want to know.<\/p>\n<h3> <a name=\"what-i-learned-along-the-way\" href=\"#what-i-learned-along-the-way\"> <\/a> What I Learned Along the Way <\/h3>\n<p>This experiment taught me a few things:<\/p>\n<ol>\n<li>\n<p>The browser already has everything.<br \/> DOMParser parses HTML. Proxy handles reactivity. addEventListener handles events. I didn&#8217;t need to invent anything \u2014 just connect what was already there.<\/p>\n<\/li>\n<li>\n<p>Less code means fewer bugs.<br \/> The entire engine is a few hundred lines. No virtual DOM, no diffing algorithm, no compiler. Less surface area for bugs.<\/p>\n<\/li>\n<li>\n<p>Constraint drives creativity.<br \/> I forced myself to use only browser APIs. No dependencies. That constraint led to a design that&#8217;s smaller than HTMX (which is server-rendered) and smaller than snabbdom (which is a VDOM library).<\/p>\n<\/li>\n<li>\n<p>It just works \u2014 even for AI agents.<br \/> 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 \u2014 no hallucination, no debugging. Because the pattern is HTML, and agents already know HTML.<\/p>\n<\/li>\n<\/ol>\n<p>What This Isn&#8217;t<\/p>\n<p>I want to be honest. HTMP isn&#8217;t for everything.<\/p>\n<div>\n<pre><code>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. <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>It&#8217;s for the in-between:<\/p>\n<div>\n<pre><code>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. <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<h3> <a name=\"where-it-stands\" href=\"#where-it-stands\"> <\/a> Where It Stands <\/h3>\n<p>HTMP is at v1.0.1. as this article wrote. It has:<\/p>\n<div>\n<pre><code>4 live examples (basic, form, SPA, note-guard) Smoke tests Zero dependencies 2.9 kB gzipped Verified by BundlePhobia Works in any browser <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>I don&#8217;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 \u2014 great.<\/p>\n<p>If you tried the test.html and it worked, I&#8217;d love to hear what you think.<\/p>\n<h3> <a name=\"links\" href=\"#links\"> <\/a> Links <\/h3>\n<p>GitHub: <a href=\"https:\/\/github.com\/erlanggasatria-source\/HTMP\" target=\"_blank\" rel=\"noopener noreferrer\">github.com\/erlanggasatria-source\/HTMP<\/a><\/p>\n<p>npm: <a href=\"https:\/\/www.npmjs.com\/package\/htm-projection\" target=\"_blank\" rel=\"noopener noreferrer\">npmjs.com\/package\/htm-projection<\/a><\/p>\n<p><em>I&#8217;m not a framework author. I&#8217;m just a developer who got tired of VDOM and decided to try something simpler. If you&#8217;re curious \u2014 copy the code, save it as test.html, and open it. That&#8217;s the whole point.<\/em><\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/erlanggasatriasource\/how-i-ended-up-with-a-29-kb-gzip-reactive-ui-engine-that-runs-from-a-static-file-1l3e\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>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&#8217;s own tools. Three APIs came to mind: [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":5192,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"webixso_pending_account_ids":""},"categories":[41],"tags":[],"class_list":["post-5193","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/5193","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=5193"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/5193\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/5192"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=5193"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=5193"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=5193"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}