DEV Community

Cover image for 1KB Frontend Library: Final Bytes
Fedor
Fedor

Posted on • Edited on

1KB Frontend Library: Final Bytes

Recap: The Journey to a 1KB Frontend Library

In the previous article, we set out to build a 1KB frontend library — a lean, no-frills toolkit that fits in just a few functions without any dependencies. Here’s what we covered:

  1. signal: For creating reactive state.
  2. effect: For running side effects when state changes.
  3. computed: For deriving reactive values.
  4. html: For declarative DOM rendering.

These four functions gave us a solid foundation for building dynamic, efficient UIs without the need for build tools. But today, we’re introducing the fifth and final function — each — to unlock efficient list rendering. Let’s dive into how each works and why it’s the perfect finishing touch!


The Problem with Brute-Force Re-Rendering

Signals allow us to pinpoint exactly which parts of the DOM depend on specific data changes, enabling us to rerender only the affected nodes or attributes.

However, when it comes to dynamic lists, the story changes. Lists often involve complex updates — adding, removing, or reordering items — that can’t be handled by simply updating individual nodes.

Let’s start with the basic way of rendering lists. Typically, we might use a .map() to iterate over an array and generate DOM elements. For example:

html`<ul>
  ${() => todos().map(
    (todo) => html`<li>${todo.text}</li>`
  )}
</ul>`;
Enter fullscreen mode Exit fullscreen mode

While this approach is straightforward, it has a significant downside: every update to the list results in a full re-render. Even if only one item in the list changes, the entire list is torn down and rebuilt. This can lead to unnecessary DOM manipulations, reflows, and degraded performance — especially as the list grows in size.

A Smarter Way to Render Lists with each

The each function is designed to solve this problem by introducing a diffing algorithm. Instead of re-rendering the entire list, each compares the current state of the list with the new state and only updates the parts of the DOM that have changed. This approach minimizes DOM operations and preserves critical DOM states like user focus, text selection, and scroll position.

To implement the each function, we’re dusting off a decade-old algorithm by Simon Friis Vindum, the creator of the widely respected snabbdom library. The resulting code is compact yet performant:

function each(val, getKey, tpl) {
  let a = [];
  let aNodes = [];
  return () => {
    const items = val?.call ? val() : val;
    const b = items.map(getKey);
    const aIdx = new Map(a.map((...kv) => kv));
    const bIdx = new Map(b.map((...kv) => kv));
    const bNodes = [];
    for (let i = 0, j = 0; i != a.length || j != b.length; ) {
      let aElm = a[i],
        bElm = b[j];
      if (aElm === null) i++;
      else if (b.length <= j) aNodes[i++].remove();
      else if (a.length <= i) bNodes.push(tpl(items[j], j++)[0]);
      else if (aElm === bElm) bNodes[j++] = aNodes[i++];
      else {
        let oldIdx = aIdx.get(bElm);
        if (bIdx.get(aElm) === undefined) aNodes[i++].remove();
        else if (oldIdx === undefined) {
          bNodes[j] = tpl(items[j], j)[0];
          aNodes[i].before(bNodes[j++]);
        } else {
          bNodes[j++] = aNodes[oldIdx];
          aNodes[i].before(aNodes[oldIdx]);
          a[oldIdx] = null;
          if (oldIdx > i + 1) i++;
        }
      }
    }
    a = b;
    aNodes = bNodes;
    return [...aNodes];
  };
}
Enter fullscreen mode Exit fullscreen mode

Here’s how it works:

html`<ul>
  ${each(
    todos,
    (todo) => todo.id,
    (todo) => html`<li>${todo.text}</li>`
  )}
</ul>`;
Enter fullscreen mode Exit fullscreen mode

The second argument to each is a function that returns a unique key for each item (e.g., item.id). These keys allow us to track which items have been added, removed, or moved, enabling precise updates to the DOM.

When the list updates, each creates a map of the old and new items using their keys. It then iterates through both lists simultaneously, comparing items based on their keys. The algorithm ensures that the DOM reflects the new list state with the fewest possible changes.

Unlike some old diffing algorithms, each allows you to use the entire item object as a key. This is possible because each leverages JavaScript’s Map under the hood, which can use objects as keys. For example:

html`<ul>
  ${each(
    todos,
    (todo) => todo, // Use the whole object as a key
    (todo) => html`<li>${todo.text}</li>`
  )}
</ul>`;
Enter fullscreen mode Exit fullscreen mode

An Updated Example: A Todo App

To showcase the power of the each function, here is an updated version of our Todo App example. Check out how it preserves DOM state like text selection.


Conclusion: A Complete 1KB Frontend Library

Our journey to build a 1KB frontend library is now complete! With just five functionssignal, effect, computed, html, and each — we’ve created a powerful, lightweight toolkit for building dynamic, efficient UIs. Clocking in at 972 bytes minified and gzipped, this library proves that you don’t need megabytes of dependencies or complex build tools to create modern web applications.

And the best part? The entire code is provided below — ready for you to copy, paste, and start using right away.

Full Code
{
  const effects = [Function.prototype];
  const disposed = new WeakSet();

  function signal(value) {
    const subs = new Set();
    return (newVal) => {
      if (newVal === undefined) {
        subs.add(effects.at(-1));
        return value;
      }
      if (newVal !== value) {
        value = newVal?.call ? newVal(value) : newVal;
        for (let eff of subs) disposed.has(eff) ? subs.delete(eff) : eff();
      }
    };
  }

  function effect(fn) {
    effects.push(fn);
    try {
      fn();
      return () => disposed.add(fn);
    } finally {
      effects.pop();
    }
  }
}

function computed(fn) {
  const s = signal();
  s.dispose = effect(() => s(fn()));
  return s;
}

{
  function html(tpl, ...data) {
    const marker = "\ufeff";
    const t = document.createElement("template");
    t.innerHTML = tpl.join(marker);
    if (tpl.length > 1) {
      const iter = document.createNodeIterator(t.content, 1 | 4);
      let n,
        idx = 0;
      while ((n = iter.nextNode())) {
        if (n.attributes) {
          if (n.attributes.length)
            for (let attr of [...n.attributes])
              if (attr.value == marker) render(n, attr.name, data[idx++]);
        } else {
          if (n.nodeValue.includes(marker)) {
            let tmp = document.createElement("template");
            tmp.innerHTML = n.nodeValue.replaceAll(marker, "<!>");
            for (let child of tmp.content.childNodes)
              if (child.nodeType == 8) render(child, null, data[idx++]);
            n.replaceWith(tmp.content);
          }
        }
      }
    }
    return [...t.content.childNodes];
  }

  const render = (node, attr, value) => {
    const run = value?.call
      ? (fn) => {
          let dispose;
          dispose = effect(() =>
            dispose && !node.isConnected ? dispose() : fn(value())
          );
        }
      : (fn) => fn(value);
    if (attr) {
      node.removeAttribute(attr);
      if (attr.startsWith("on")) node[attr] = value;
      else
        run((val) => {
          if (attr == "value" || attr == "checked") node[attr] = val;
          else
            val === false
              ? node.removeAttribute(attr)
              : node.setAttribute(attr, val);
        });
    } else {
      const key = Symbol();
      run((val) => {
        const upd = Array.isArray(val)
          ? val.flat()
          : val !== undefined
          ? [document.createTextNode(val)]
          : [];
        for (let n of upd) n[key] = true;
        let a = node,
          b;
        while ((a = a.nextSibling) && a[key]) {
          b = upd.shift();
          if (a !== b) {
            if (b) a.replaceWith(b);
            else {
              b = a.previousSibling;
              a.remove();
            }
            a = b;
          }
        }
        if (upd.length) (b || node).after(...upd);
      });
    }
  };
}

function each(val, getKey, tpl) {
  let a = [];
  let aNodes = [];
  return () => {
    const items = val?.call ? val() : val;
    const b = items.map(getKey);
    const aIdx = new Map(a.map((...kv) => kv));
    const bIdx = new Map(b.map((...kv) => kv));
    const bNodes = [];
    for (let i = 0, j = 0; i != a.length || j != b.length; ) {
      let aElm = a[i],
        bElm = b[j];
      if (aElm === null) i++;
      else if (b.length <= j) aNodes[i++].remove();
      else if (a.length <= i) bNodes.push(tpl(items[j], j++)[0]);
      else if (aElm === bElm) bNodes[j++] = aNodes[i++];
      else {
        let oldIdx = aIdx.get(bElm);
        if (bIdx.get(aElm) === undefined) aNodes[i++].remove();
        else if (oldIdx === undefined) {
          bNodes[j] = tpl(items[j], j)[0];
          aNodes[i].before(bNodes[j++]);
        } else {
          bNodes[j++] = aNodes[oldIdx];
          aNodes[i].before(aNodes[oldIdx]);
          a[oldIdx] = null;
          if (oldIdx > i + 1) i++;
        }
      }
    }
    a = b;
    aNodes = bNodes;
    return [...aNodes];
  };
}
Enter fullscreen mode Exit fullscreen mode

Top comments (8)

Collapse
 
benny00100 profile image
Benny Schuetz

It is really great to see what you have come up with in just 972 bytes. Wish a lot of evangelist of those "major" frameworks see this and realize how those frameworks have grown increasingly bloated and complex.

So again - very good work !

PS:
About the re-rendering I came across this tweet here

Collapse
 
olegkorol profile image
Oleg Korol

I'm curious what the tweet is :)

Collapse
 
benny00100 profile image
Benny Schuetz

The direct link just shows a video - so it's safe to click on it although the description says something about a possible browser crash ;-)

Thread Thread
 
olegkorol profile image
Oleg Korol

Ah ok. I just do not see any link – after "I came across this tweet here" there's nothing else to see.
Is it me or is dev.to removing links from the comments?

Thread Thread
 
benny00100 profile image
Benny Schuetz

Huh ... that's strange. I use the embed function of the Dev.to editor. For me it previews the tweet.
Here is the unembedded direct link to the tweet I was sharing:

x.com/phoboslab/status/18774185970...

Collapse
 
artydev profile image
artydev

Congratulations🙂

Collapse
 
viorelmocanu profile image
Viorel Mocanu

Excellent work!
I suggest you plop it in a GitHub repo so people can star it at least (since using it in the code is so easy as copy-pasting a few lines of JS).

Collapse
 
fedia profile image
Fedor

Thank you! If bookmarking code on GitHub is more convenient, I’ll probably push it there. Appreciate the advice!