tiny dom magic for big ideas 🎉
fresh and light 🍃
purify.js is a 1.0kB (minified, gzipped) DOM utility library, focusing on building reactive UI.
import { ref, tags } from "@purifyjs/core";
const { div, button } = tags;
export function Hello() {
const counter = ref(0);
return div().append$(
["Hello, ", counter.derive((n) => new Array(n).fill("👋"))],
button().onclick(() => counter.val++).textContent("Hi!"),
);
}
document.body.append(Hello().$node);
- Keeps you close to the DOM.
- Works well with existing DOM methods, properties and features.
- Everything you can do with DOM is allowed, and expected.
- Allows direct DOM manipulation.
- Doesn't break after a direct DOM manipulation.
- Allows you to work with any
Node
instance using the builder pattern,Element
,ShadowRoot
,DocumentFragment
,Document
, evenAttr
and any future ones. - Allows you to bind any custom lifecycle logic to
HTMLElement
(s) with lifecycle.
- Everything reactive is just signals.
- Signals are extendable, allowing chaining with utilities like
.pipe()
and.derive()
to build custom workflows.
- No special file extensions.
- Only deal with
ts/js
files. - 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/Javascript code.
deno add jsr:@purifyjs/core
npx jsr add @purifyjs/core # npm
bunx jsr add @purifyjs/core # bun
yarn dlx jsr add @purifyjs/core # yarn
pnpm dlx jsr add @purifyjs/core # pnpm
Importing
import { ... } from "jsr:@purifyjs/core";
import { ... } from "https://esm.sh/jsr/@purifyjs/core";
Import Maps
<script type="importmap">
{
"imports": {
"@purifyjs/core": "https://esm.sh/jsr/@purifyjs/core"
}
}
</script>
{
"imports": {
"@purifyjs/core": "jsr:@purifyjs/core"
}
}
{
"imports": {
"@purifyjs/core": "https://esm.sh/jsr/@purifyjs/core"
}
}
Coming soon. At 1.0.0 if not sooner. I don't wanna write something that will become outdated 6 months later.
import { Builder, Lifecycle, ref, Sync, sync, tags, track } from "@purifyjs/core";
const { button, ul, li, input } = tags;
const time = sync<number>((set) => {
const update = () => set(Date.now());
const interval = setInterval(update, 1000);
update();
return () => clearInterval(interval);
});
const count = ref(0);
const double = count.derive((count) => count * 2);
const half = computed(() => count.val * 0.5);
new Builder(document.body).append$(
button()
.onclick(() => count.val--)
.textContent("-"),
input().type("number")
.$bind(useValueAsNumber(count))
.step("1"),
button()
.onclick(() => count.val++)
.textContent("+"),
ul().append$(
li().append$("Count: ", count),
li().append$("Double: ", double),
li().append$("Half: ", half),
li().append$("Time: ", time),
),
);
function useValueAsNumber(
state: Sync.Ref<number>,
): Lifecycle.OnConnected<HTMLInputElement> {
return (element) => {
const abortController = new AbortController();
element.addEventListener(
"input",
() => (state.val = element.valueAsNumber),
{ signal: abortController.signal },
);
const unfollow = state.follow(
(value) => (element.valueAsNumber = value),
true,
);
return () => {
abortController.abort();
unfollow();
};
};
}
import { Builder, ref, tags } from "@purifyjs/core";
const { div, button } = tags;
function Counter() {
const host = div();
const shadow = new Builder(host.$node.attachShadow({ mode: "open" }));
const count = ref(0);
shadow.append$(
button()
.title("Click me!")
.onclick(() => count.val++)
.append$("Count:", count),
);
return host;
}
import { Builder, ref, tags, WithLifecycle } from "@purifyjs/core";
const { button } = tags;
class CounterElement extends WithLifecycle(HTMLElement) {
static {
customElements.define("x-counter", CounterElement);
}
#count = ref(0);
constructor() {
super();
const self = new Builder<CounterElement>(this);
self.append$(
button()
.title("Click me!")
.onclick(() => this.#count.val++)
.append$("Count:", this.#count),
);
}
}
-
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 anHTMLImageElement
somewhere in the code but all JSX returns isHTMLElement
orJSX.Element
. It also has issues with 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, you can clearly distinguish between attributes and properties while building elements, 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.
Will purify.js ever support SSR?
No. And it never will.
purify.js is a DOM utility library, not a framework. It’s built for the browser — where apps are meant to actually run.
Supporting SSR means sacrificing what makes SPAs powerful. It breaks the direct connection with the DOM — the very thing purify.js is designed to embrace.
Let’s be honest: SSR has no place in the future of the web.
Projects like Nostr, Cachu, Blossom, IPFS, and others are shaping a web that’s decentralized, distributed, and browser-native.
That world doesn’t need server-rendered HTML. It needs small, portable apps that run fully in the client — fast, simple, self-contained, and aggressively cached.
purify.js is built for that world.
The problem was never the SPA.
The problem was React — and the bloated, over-engineered mess it encouraged.
Embrace SPA. Embrace PWA.
Heck, bundle everything into a single HTML file.
Servers don’t need to render UI — that’s the browser’s job. Rendering isn’t just data, it’s behavior. Offload that computation. Distribute
it. Don’t centralize it.
Your frontend should be nothing more than a CDN-hosted file.
You don’t need a thousand nodes rendering your UI logic around the world.
Let the browser do what it was built to do.
Full-fledged dashboard built for a private project, running entirely with purify.js and PicoCSS. SSR is overrated.
-
Since purify.js uses extended custom elements, Safari doesn’t support this yet. If you care about Safari for some reason, use the ungap/custom-elements polyfill. You can follow support status at caniuse.
But I don’t recommend that you support Safari.
Don't suffer for Safari, let Safari users suffer.
-
Right now, when a
Signal
is connected to the DOM viaBuilder
, it updates all children of theParentNode
withParentNode.prototype.replaceChildren()
.This is obviously not great. In version
0.1.6
, I was using a<div>
element withdisplay:contents
to wrap a renderedSignal
in the DOM. This allowed tracking its lifecycle viaconnectedCallback
/disconnectedCallback
, making cleanup easier.However, wrapping it with an
HTMLElement
caused CSS selector issues, since eachSignal
became an actualHTMLElement
.So, in version
0.2.0
, I made it so that all children of aParentNode
update when aSignal
child changes. This issue can be managed by structuring code carefully or using.replaceChild()
, since all nodes now supportSignal
(s).
UPDATE: Switched back to using
<div>
withdisplay:contents
.
Some might ask, "Why not just use comment nodes?" Yes, using comment nodes for tracking ranges is a traditional solution. But it’s not a native ranging solution, and frameworks that rely on it break if the DOM is mutated manually, which goes against this library’s philosophy.
The real solution? JavaScript needs a real
DocumentFragment
with persistent children.A relevant proposal:
DOM#739 Proposal: a DocumentFragment whose nodes do not get removed once inserted.However, they propose making the fragment undetectable via
childNodes
orchildren
, which I don’t support. ADocumentFragment
should be aParentNode
with its own children, and it should behave hierarchically like any otherParentNode
.But it’s a start. However, just having a working DocumentFragment is not enough.
-
We also need a native, synchronous, and easy way to follow the lifecycle of any
ChildNode
(or at leastElement
and the proposed persistentDocumentFragment
).An open issue on this:
DOM#533 Make it possible to observe connected-ness of a node.Right now, Custom Elements are the only sync way to track element lifecycle. This is why purify.js heavily relies on them. We auto-create Custom Elements via the
tags
proxy andWithLifecycle
HTMLElement
mixin. -
If the above feature is not introduced soon, we also keep an eye on this proposal:
webcomponents#1029 Proposal: Custom attributes for all elements, enhancements for more complex use cases.This doesn’t solve the DocumentFragment issue but improves and modularizes
HTMLElement
lifecycles.Currently, we use a mixin function called
WithLifecycle
, like this:WithLifecycle(HTMLElement); // or WithLifecycle(HTMLDivElement);
It adds a
$bind()
lifecycle function to anyHTMLElement
. Later, it can be extended into a custom element:class MyElement extends WithLifecycle(HTMLElement)
This allows defining custom
HTMLElement
types with lifecycles. Thetags
proxy also usesWithLifecycle
internally.So when you do:
tags.div();
You’re actually getting a
<div is="pure-div">
with lifecycle tracking. The[is]
attribute is invisible in the DOM because the element is created via JavaScript, not HTML.However, since this method requires you to decide lifecycle elements ahead of time, it also means we must create "pure-*" versions of native elements. While it makes sense, it’s a bit cumbersome.
This is why the custom attributes proposal could significantly improve how lifecycles work. It would make lifecycle-related behavior explicit in the DOM, which is a big advantage.
-
Something like
.toNode()
orSymbol.toNode
would allow us to insert anything into the DOM without manually unwrapping them. This would simplify DOM manipulation by letting custom objects, structures, or even signals to be automatically converted into valid DOM nodes when inserted.