DEV Community

Erlangga Satria
Erlangga Satria

Posted on

Reactivity Without the Framework: How to Get React-Like Behavior in a Single HTML File

Copy the code below into test.html, open it in your browser, and you have a reactive app running from a static file. No build step. No npm install. No server. Just HTML and a 2.9 kB library.

The Problem: Reactivity Shouldn't Require a Framework

Modern web development has a secret: to get simple reactivity—a counter, a todo list, a form that updates live—you often need:

  • A build tool (Vite, Webpack)
  • A package manager (npm, yarn)
  • A framework (React, Vue, Angular)
  • A state management library (Redux, Pinia)
  • 40+ kB of JavaScript

And after all that, you still have to learn JSX, hooks, lifecycle methods, and a dozen other concepts.

For many developers—especially those in PHP, WordPress, or legacy environments—this is overkill. They just want a button that updates a count, a list that adds items, a form that feels alive.

That's where HTMP comes in.

The Solution: HTMP — HyperText Mutation & Projection

HTMP is a zero-dependency, browser-native reactive UI engine built on three pillars:

  1. Pattern – your HTML blueprint, parsed once by the browser's DOMParser

  2. Proxy – reactive state via native Proxy

  3. Program – your methods and event handlers

It weighs 2.9 kB gzipped. It has no dependencies. It works in any browser. And you can run it from a file:// URL.

No build. No npm. No framework.

Try It Yourself (30 Seconds)

  1. Create a file called test.html on your desktop.

  2. Paste the code below into it.

  3. Save and double-click the file.


<!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 App</button>
  <button onclick="app.remount()">Remount App</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. Add one below!' : '' }}</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>

Enter fullscreen mode Exit fullscreen mode

Open it in your browser. You'll see:

  • A title that updates as you type.

  • A counter that increments.

  • A todo list that adds, finishes, and deletes items.

  • Buttons to unmount and remount the entire app—without losing state.

All from a static file. No server. No build.

Why This Works (The 3 Pillars)

P1 Pattern — The HTML Blueprint

HTMP parses your HTML string once using the browser's native DOMParser. Every dynamic binding— {{ }}, :for, :class, @click —is registered with a path to its DOM node.


<li :for="todo in todos">
  <span :class="todo.done ? 'done' : ''">{{ todo.text }}</span>
</li>

Enter fullscreen mode Exit fullscreen mode

No JSX. No compiler. Just HTML.

P2 Proxy — The Reactive State

State lives in a Proxy object. When you change a property, HTMP knows exactly which bindings need updating.


app.proxy.todos = [...app.proxy.todos, newTodo];

Enter fullscreen mode Exit fullscreen mode

No diffing. No virtual DOM. Just a proxy trap.

P3 Program — Your Methods

Event handlers are plain JavaScript functions. No hooks, no lifecycle methods.


app.setProgram({
  addTodo: () => {
    app.proxy.todos = [...app.proxy.todos, newTodo];
  }
});

Enter fullscreen mode Exit fullscreen mode

No useState. No useEffect. Just functions.

The Magic: Slot-Based Rendering

Notice this line in the pattern:


<p><i>{{ todos.length === 0 ? 'No todos yet. Add one below!' : '' }}</i></p>

Enter fullscreen mode Exit fullscreen mode

In React or Vue, you'd need a conditional (v-if, {condition && <p>}) to avoid rendering an empty element. In HTMP, the pattern is always there—the expression just evaluates to an empty string. The <p> remains, but it's invisible because it has no content.

No conditional rendering needed. The template is stable. State fills the slots.

The Bonus: Unmount & Remount

HTMP includes a lifecycle: mount(), unmount(), remount(), destroy().

In the example, the buttons let you unmount the app (remove it from the DOM) and remount it (put it back)—without losing state. This is perfect for SPAs or for widgets that need to be hidden and shown.

How It Compares

Feature React Vue HTMX HTMP
Build step Yes Optional No No
npm install Yes Yes No No
Bundle size ~40 kB ~30 kB ~14 kB 2.9 kB
Dependencies Many Many 0 0
Reactivity Yes Yes No Yes
Works from file:// No No Yes Yes
Learning curve High Medium Low Very Low

HTMP is smaller than HTMX and gives you true client-side reactivity—without a build step.

Who Is This For?

  • PHP / Laravel / CodeIgniter developers who want reactivity without leaving their stack.

  • WordPress developers who need interactive widgets in a legacy environment.

  • jQuery users who want to modernize without learning React.

  • Anyone who needs a small, fast, reactive UI for a widget, a form, or a simple SPA.

  • AI agents – HTMP is so intuitive that LLMs can generate and refactor it without prior training.

Try It Live

Want to see more? These examples run entirely in your browser:

Basic Example – Title editor, counter, todo list.

Multi-Step Form – Step-by-step form with validation.

SPA Ticket App – Routing with History API and 404 fallback.

Note Guard with Polaris Runtime – Integration with a workflow engine.

Get Started


npm install htm-projection

Enter fullscreen mode Exit fullscreen mode

Or use it directly from CDN:


<script type="module">
  import { HTMP } from 'https://unpkg.com/htm-projection@latest/dist/esm/index.js';
</script>

Enter fullscreen mode Exit fullscreen mode

GitHub: github.com/erlanggasatria-source/HTMP

npm: npmjs.com/package/htm-projection

BundlePhobia: 2.9 kB gzipped, zero dependencies.

The Bottom Line

Reactivity should not require a framework.

HTMP proves that you can have React-like reactivity with just HTML, a tiny proxy-based engine, and no build step. It's not a replacement for React or Vue in large applications—but for the thousands of smaller projects, widgets, and legacy integrations, it's a breath of fresh air.

Copy the code, save it as test.html, open it. That's it.

Welcome to reactivity without the weight.

Top comments (0)