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:
-
signal
: For creating reactive state. -
effect
: For running side effects when state changes. -
computed
: For deriving reactive values. -
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>`;
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];
};
}
Here’s how it works:
html`<ul>
${each(
todos,
(todo) => todo.id,
(todo) => html`<li>${todo.text}</li>`
)}
</ul>`;
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>`;
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 functions — signal
, 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];
};
}
Top comments (8)
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
I'm curious what the tweet is :)
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 ;-)
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?
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...
Congratulations🙂
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).
Thank you! If bookmarking code on GitHub is more convenient, I’ll probably push it there. Appreciate the advice!