DEV Community

Jahongir Sobirov
Jahongir Sobirov

Posted on

I Built a Tiny Reactive JS Library and Discovered Why v-model Exists

I Built a Tiny Reactive JS Library — and Rediscovered Why v-model Exists

When you use frameworks like Vue or React, a lot of things “just work”.
Recently, I tried to build a tiny reactive JavaScript library called Signel.js, and I learned why those things exist — the hard way.

Why I built it

I wanted to understand reactivity better, so I built a small library using:

  • Proxy for state
  • simple template interpolation ($$value)
  • manual DOM bindings

No virtual DOM. No JSX. Just raw JavaScript.

The demo: currency converter

I built a small USD → UZS currency converter using a free exchange-rate API.

let state = el(".exchange", {
  usdAmount: "",
  uzsAmount: ""
});
Enter fullscreen mode Exit fullscreen mode

I bound the inputs like this:

input("#usd", state, "usdAmount");
input("#uzs", state, "uzsAmount");
Enter fullscreen mode Exit fullscreen mode

And watched the USD value:

watch(state, "usdAmount", async value => {
  const res = await fetch(
    "https://hexarate.paikama.co/api/rates/USD/UZS/latest"
  );
  const data = await res.json();

  state.uzsAmount = (value * data.data.mid).toFixed(2);
});
Enter fullscreen mode Exit fullscreen mode

The bug that confused me

Here’s what happened:

  • $$uzsAmount in the template updated correctly ✅
  • but the <input id="uzs"> did not update

At first, I thought:

  • API issue?
  • async bug?
  • state not reactive?

But the state was changing.

The real problem

My el() function re-rendered the element like this:

el.innerHTML = template.replace(...)
Enter fullscreen mode Exit fullscreen mode

That means on every state change:

  • inputs were destroyed
  • new inputs were created
  • old event listeners and bindings were lost

So input() was bound to a DOM element that no longer existed.
This explains why:

  • text interpolation worked
  • inputs silently failed

The fix: model() instead of input()

I already had a model() helper inspired by v-model.

model("#usd", state, "usdAmount");
model("#uzs", state, "uzsAmount");
Enter fullscreen mode Exit fullscreen mode

model() stores state → DOM bindings as functions, so even after re-renders, the input value is updated correctly.

After switching to model():

  • ✅ USD input works
  • ✅ UZS input updates
  • ✅ Text interpolation still works

What I learned

This bug taught me more than any tutorial:

  • Why frameworks avoid full innerHTML re-renders
  • Why form bindings are special
  • Why v-model-style abstractions exist
  • Why DOM diffing and controlled inputs matter

I accidentally rediscovered a problem that led to React, Vue, and Svelte.

Final thoughts

Building tiny libraries is an amazing way to understand big frameworks.

If you want to learn how reactivity really works:

  • build something small
  • break it
  • fix it

That’s how frameworks are born.

🔗 Signel.js (MIT, experimental)
Online demo

Top comments (0)