PoV:
You donโt want to use a frontend framework because you think itโs overkill.
You want to use Vanilla JS to manipulate the DOM,
but then you realize your code gets messy, filled with so many
document.createElement
, element.setAttribute
, & element.appendChild
.
Youโre considering a minimal abstraction with an extremely small bundle size (1.7ย KB).
Combine the declarative React-like syntax with the flexibility of vanilla JS. ๐
<div id="example-render"></div>
<script>
const myElement = document.querySelector('#example-render');
$(myElement, { className: 'bg-yellow-300 rounded my-3 p-3' }, [
$('div', {}, 'Hello world!'),
$('ul', { className: 'list-disc pl-5' }, [
$('li', {}, 'Foo'),
$('li', {}, ['Bar ', $('b', { className: 'font-bold' }, 'Baz')]),
]),
]);
</script>
By default, there is no re-rendering at all.
You control which HTML elements should be re-rendered.
<div id="example-state"></div>
<script>
const MyComponent = (initialCounter = 1) => {
const counter = $.createState(initialCounter);
return $('div', { className: 'bg-orange-300 p-2 rounded flex gap-2' }, [
$('div',
{ ref: counter.ref }, // ๐ put ref in any element you want to re-render
['Count: ', counter.get]),
$('div',
// ๐ Use function for dynamic props
() => ({
ref: counter.ref,
className: ['size-3 rounded-full', counter.current % 2 ? 'bg-white' : 'bg-black'],
})
),
$('div',
{ className: 'ml-auto' },
'No re-rendered here'),
$('button',
{ onClick: () => counter.set((prev) => prev + 1) },
'Increment'
),
]);
};
$(
document.querySelector('#example-state'),
{ className: 'bg-yellow-300 rounded my-3 p-3 space-y-2' },
[
MyComponent, // A function (a.k.a component)
MyComponent(), // An element
MyComponent(30), // An element
],
);
</script>
Here is an example of a simple to-do app that covers both global & local state.
<div id="example-to-do"></div>
<script>
// Global state
const search = $.createState('');
const searchElement = $('input', {
placeholder: 'Search on both...',
className: 'p-2 rounded w-full',
onInput: (e) => search.set(e.target.value.toLowerCase()),
});
const ToDo = ({ heading, initialToDos }) => {
// Local state
const todos = $.createState(initialToDos);
return $('div', { className: 'flex-1 flex flex-col' }, [
$('h3', { className: 'text-lg font-bold' }, [heading, "'s To-Do:"]),
$(
'ul',
{
className: 'space-y-1 pb-3 pt-1 flex-1',
ref: [todos.ref, search.ref], // ๐ Re-render when todos or search changed
},
() => {
return todos.current.map((todo) => {
if (!todo.title.toLowerCase().includes(search.current)) return null;
return $('li', {}, [
$('div', { className: 'flex gap-1.5' }, [
$('button', {
className: 'px-2 bg-rose-300 rounded',
textContent: 'x',
onClick: () => todos.set((prev) => prev.filter((item) => item !== todo)),
}),
$('div', {}, todo.title),
]),
]);
});
}
),
AddToDo((newTodo) => todos.set((prev) => [...prev, newTodo])),
]);
};
const AddToDo = (onSubmit) => {
const ref = {};
return $(
'form',
{
className: 'bg-orange-300 p-1.5 rounded space-y-1.5 *:w-full *:rounded *:px-2.5 *:py-1',
onSubmit: (e) => {
e.preventDefault();
if (!ref.element.value) {
ref.element.focus();
return;
}
onSubmit({ title: ref.element.value });
e.target.reset();
if (searchElement.value) {
search.set('');
searchElement.value = '';
}
},
},
[
$('input', { ref, placeholder: 'Input title...', className: 'p-2 rounded' }),
$('button', { className: 'px-2 bg-emerald-200 rounded' }, ['Add']),
]
);
};
$(
document.querySelector('#example-to-do'),
{ className: 'p-2 bg-yellow-300 rounded' },
[
searchElement,
$('div', { className: 'flex gap-2 pt-3 items-stretch' }, [
ToDo({
heading: 'Foo',
initialToDos: [{ title: 'Buy milk' }, { title: 'Buy tomato' }, { title: 'Shear sheep' }, { title:'Catch fish' }],
}),
ToDo({
heading: 'Bar',
initialToDos: [{ title: 'Eat tomato' }, { title: 'Buy chocolate' }],
}),
]),
]
);
</script>
You can copy the code here:
https://github.com/afiiif/dom-dom-factory/blob/main/lib/dom.ts
Add this minified script ๐ Only 1.7 KB
{let v=(e,p={},u=[])=>{let y=e instanceof HTMLElement?e:document.createElement(e),m=(e="all")=>{var t="function"==typeof p?p():p;let r=new Map;if("all"===e||"props"===e)for(var[n,a]of Object.entries(t))if(void 0!==a)if("ref"===n){var i,o=(e="all")=>{if("all"===e||"props"===e){for(;0<y.attributes.length;)y.removeAttribute(y.attributes[0].name);r.forEach((e,t)=>{y.removeEventListener(t,e)})}"all"!==e&&"children"!==e||(y.innerHTML=""),m(e)};for(i of Array.isArray(a)?a:[a])"function"==typeof i?i({element:y,render:o}):i instanceof Map?i.set(y,o):(i.element=y,i.render=o)}else if(n.startsWith("on")){var s=n.substring(2).toLowerCase();y.addEventListener(s,a),r.set(s,a)}else if("className"===n)a&&(s=Array.isArray(a)?a:[a],y.className=s.filter(Boolean).join(" "));else if("style"===n)for(var[f,l]of Object.entries(a))void 0!==l&&(y.style[f]=l);else if("data"===n)for(var[c,d]of Object.entries(a))void 0!==d&&(y.dataset[c]=d);else n.startsWith("aria")?y.setAttribute(n,String(a)):y[n]=a;"all"!==e&&"children"!==e||v.append(y,u)};return m(),y};v.append=(e,t)=>{var r,n;for(r of Array.isArray(t)?t:[t])r instanceof HTMLElement?e.appendChild(r):"string"==typeof r||"number"==typeof r?e.appendChild(document.createTextNode(String(r))):"function"==typeof r&&((n=r())instanceof HTMLElement?e.appendChild(n):"string"==typeof n||"number"==typeof n?e.appendChild(document.createTextNode(String(n))):Array.isArray(n)&&v.append(e,n));return e},v.createRef=()=>{let e=new Map;return Object.assign(e,{render:t=>{e.forEach(e=>e(t))}})},v.createState=e=>{let t=v.createRef(),r={current:e,ref:t,get:()=>r.current,set:e=>{r.current="function"==typeof e?e(r.current):e,t.render()}};return r},window.$=v}