tiny dom magic for big ideas 🎉
fresh and light ✨🍃
purify.js is a 1.0kB (minified, gzipped) 1.0kB DOM utility library, focusing on building reactive UI. 🚀
- 🔥 Keeps you close to the DOM.
- ✍️
HTMLElement
builder allows you to differentiate between attributes and properties. - 🌐 Builder doesn't only work with
HTMLElement
(s) but works with anyNode
instance includingShadowRoot
,DocumentFragment
,Document
... anyNode
type, including future ones. - 🎩 Builder converts existing methods on the
Node
instance to builder pattern withProxy
. - ⚡ Uses signals for reactivity.
- 🧙 Signals are extendable, allowing chaining with utilities like .pipe() and .derive() to build custom workflows..
- ✂️ Allows direct DOM manipulation.
- 📁 No special file extensions.
- 🔧 Only deal with
.ts
files, so use it with any existing formatting, linting, and other tools. - ⚡ No extra LSP and IDE extensions/plugins: fast IDE responses, autocompletion, and no weird framework specific LSP issues.
- ✅ All verifiable TypeScript code.
Package | .min.js | .min.js.gz |
---|---|---|
purify.js | 2.2kB | 1.0kB |
Preact 10.19.3 | 11.2kB | 4.5kB |
Solid 1.8.12 | 23kB | 8.1kB |
jQuery 3.7.1 | 85.1kB | 29.7kB |
Vue 3.4.15 | 110.4kB | 40kB |
ReactDOM 18.2.0 | 130.2kB | 42kB |
Angular 17.1.0 | 310kB | 104kB |
To install purify.js, follow the jsr.io/@purifyjs/core.
import { computed, Lifecycle, ref, Signal, tags } from "@purifyjs/core";
const { div, section, button, ul, li, input } = tags;
function App() {
return div().id("app").replaceChildren$(Counter());
}
function Counter() {
const count = ref(0);
const double = count.derive((count) => count * 2);
const half = computed(() => count.val * 0.5);
return div().replaceChildren$(
section({ class: "input" })
.ariaLabel("Input")
.replaceChildren$(
button()
.title("Decrement by 1")
.onclick(() => count.val--)
.textContent("-"),
input().type("number").$effect(useBindNumber(count)).step("1"),
button()
.title("Increment by 1")
.onclick(() => count.val++)
.textContent("+"),
),
section({ class: "output" })
.ariaLabel("Output")
.replaceChildren$(
ul().replaceChildren$(
li().replaceChildren$("Count: ", count),
li().replaceChildren$("Double: ", double),
li().replaceChildren$("Half: ", half),
),
),
);
}
function useBindNumber(
state: Signal.State<number>,
): Lifecycle.OnConnected<HTMLInputElement> {
return (element) => {
const listener = () => (state.val = element.valueAsNumber);
element.addEventListener("input", listener);
const unfollow = state.follow(
(value) => (element.valueAsNumber = value),
true,
);
return () => {
element.removeEventListener("input", listener);
unfollow();
};
};
}
document.body.append(App().$node);
import { Builder, ref, tags } from "@purifyjs/core";
const { div, button } = tags;
function App() {
return div().id("app").replaceChildren$(Counter());
}
function Counter() {
const host = div();
const shadow = new Builder(host.$node.attachShadow({ mode: "open" }));
const count = ref(0);
shadow.replaceChildren$(
button()
.title("Click me!")
.onclick(() => count.val++)
.replaceChildren$("Count:", count),
);
return host;
}
document.body.append(App().$node);
import { Builder, ref, tags, WithLifecycle } from "@purifyjs/core";
const { div, button } = tags;
function App() {
return div().id("app").replaceChildren$(new CounterElement());
}
declare global {
interface HTMLElementTagNameMap {
"x-counter": CounterElement;
}
}
class CounterElement extends WithLifecycle(HTMLElement) {
static {
customElements.define("x-counter", CounterElement);
}
#count = ref(0);
constructor() {
super();
const self = new Builder<CounterElement>(this);
self.replaceChildren$(
button()
.title("Click me!")
.onclick(() => this.#count.val++)
.replaceChildren$("Count:", this.#count),
);
}
}
document.body.append(App().$node);
Coming soon.
-
Lack of Type Safety: An
<img>
element created with JSX cannot have theHTMLImageElement
type because all JSX elements must return the same type. This causes issues if you expect aHTMLImageElement
some where in the code but all JSX returns isHTMLElement
or something likeJSX.Element
. Also, it has some other issues related to the generics, discriminated unions and more. -
Build Step Required: JSX necessitates a build step, adding complexity to the development workflow. In contrast, purify.js avoids this, enabling a simpler and more streamlined development process by working directly with native JavaScript and TypeScript.
-
Attributes vs. Properties: In purify.js, I can differentiate between attributes and properties of an element while building it, which is not currently possible with JSX. This distinction enhances clarity and control when defining element characteristics.
JSX is not part of this library natively, but a wrapper can be made quite easily.
-
Since I use extended custom elements, safari doesn't support this yet, so if you care about safari for some reasons, use ungap/custom-elements polyfill. You can follow support at caniuse.
But I don't recommend that you support Safari.
Don't suffer for Safari, let the Safari users suffer
-
Right now, when a
Signal
is connected to DOM viaBuilder
, we update all of the children of theParentNode
withParentNode.prototype.replaceChildren()
.This is obviously not that great, previously at
0.1.6
I was using a<div>
element with the styledisplay:contents
to wrap a renderedSignal
on the DOM. This was also allowing me to follow it's lifecyle viaconnectedCallback
/disconnectedCallback
which was allowing me to follow or unfollow theSignal
, making cleanup easier.But since we wrap it with an
HTMLElement
it was causing problems with CSS selectors, since now eachSignal
is anHTMLElement
on the DOM.So at
0.2.0
I made it so that all children of theParentNode
updates when aSignal
child changes. Tho this issue can be escaped by seperating things while writing the code. Or make use of things like.replaceChild()
. Since all supportSignal
(s) now.You might be saying "Why not just use comment nodes?": Yes, creating ranges with comment nodes is the traditional solution to this issue. But it's not a native ranging solution, and the frameworks that use it break as soon as you mutate the DOM without the framework, which is against the philosophy of the library.
So to solve the core of this issue JS needs a real
DocumentFragment
with persistent children.This proposal might solve this issue: DOM#739 Proposal: a DocumentFragment whose nodes do not get removed once inserted.
In the proposal they propose making the fragment undetactable with
childNodes
orchildren
which I am against and don't like at all.DocumentFragment
should be aParentNode
should have it's own children, and can beChildNode
of otherParentNode
. Normal hierarchy, no trasparency other than CSS.But it's a good start, but just by having a real, working as intended,
DocumentFragment
we are not done.Which brings be to the next point.
-
We also need a native, sync and easy to use way to follow lifecycle of any DOM
ChildNode
, or at least allElement
and this new persistentDocumentFragment
. Because without a lifecycle feature we can't bind aSignal
to the DOM, start, stop/cleanup them automatically.An issue is open here DOM#533 Make it possible to observe connected-ness of a node.
But also, DOM already has a sync way to follow lifecycle of custom
HTMLElement
(s). And since this is the only way, at this time we heavily relay on that. Currently we use auto created Custom Elements viatags
proxy andWithLifecycle
HTMLElement
mixin. And allowSignal
related things only on those elements. -
If this feature above doesn't come sooner we also keep an eye of this other proposal which has more attraction: webcomponents#1029 Proposal: Custom attributes for all elements, enhancements for more complex use cases
This proposal doesn't fix the issue with
DocumentFragment
(s), but improves and makesHTMLElement
based lifecycles more modular and DX friendly.Right now, we have a mixing function called
WithLifecycle
which can be used like:WithLifecycle(HTMLElement); // or WithLifecycle(HTMLDivElement);
It adds a lifecycle function called
$effect()
to anyHTMLElement
type. Which can later be extended by a custom element likeclass MyElement extends WithLifecycle(HTMLElement)
Allowing you to create your own custom
HTMLElement
type with lifecycle. thetags
proxy also usesWithLifecycle
in combination withBuilder
internally. so when you dotags.div()
you are actually getting a<div is="pure-div">
with a lifecycle. But the[is]
attribute is not visible in the DOM since this element created by JS, not HTML.Anyway since this method requires you to decide if something is an element with lifecycle ahead of time, and also requires use to create
pure-*
variant of nativeHTMLElement
types in order to make them have lifecycle, it's kinda a lot. It makes sense. But it's kind of a lot.So this new custom attributes proposal can let us have lifecycle on any
Element
easily by simily adding an attribute to it. And this can reshape a big portion of this codebase. And would make things connected to lifecyle of theElement
more visible in the DOM. Which is great.