Welcome to purify.js—a lightweight (≈1 kB) reactive DOM utility library focused on simplicity and performance. This guide will walk you through all aspects of using purify.js to build reactive UIs.
At the core of purify.js is a powerful signal-based reactivity system. Unlike heavyweight frameworks, purify.js implements reactivity with minimal abstractions:
import { ref, sync } from "@purifyjs/core";
// Create a writable signal with an initial value
const count = ref(0);
// Read from a signal - two equivalent ways:
console.log(count.get()); // 0
console.log(count.val); // 0 (shorthand property for get())
// Write to a signal - two equivalent ways:
count.set(5); // Method approach
count.val = 5; // Property approachUnder the hood, signals are lightweight objects that track dependencies and notify subscribers when their values change. Every signal provides both
.get()/.set()methods and a.valproperty that triggers the same functionality.
All signals in purify.js are built on the Sync class:
Syncis the base class for all signalsSync.RefextendsSyncto add mutability (for read-write signals)
While you can create signals with constructors like new Sync() or new Sync.Ref(), purify.js provides convenient function aliases:
// These are equivalent:
const time = sync((set) => {/* implementation */}); // Function alias
const time = new Sync((set) => {/* implementation */}); // Constructor
// These are equivalent too:
const count = ref(0); // Function alias
const count = new Sync.Ref(0); // ConstructorTip: The function aliases are preferred for their conciseness and readability.
purify.js offers two kinds of signals:
A mutable signal you can both read and write.
A signal with manual control over its lifecycle:
const time = sync<number>((set) => {
// This runs when the signal gets its first follower
const interval = setInterval(() => set(Date.now()), 1000);
// This cleanup runs when the signal has no more followers
return () => clearInterval(interval);
});To react to signal changes, use the .follow() method:
// Register a callback that runs whenever count changes
const stopFollowing = count.follow((value) => {
console.log(`Count is now ${value}`);
}, true); // The 'true' means run immediately with current value
// Later, to stop following:
stopFollowing();Inside
.follow(), purify.js tracks when signals start and stop being used. When a signal has no followers, its resources can be cleaned up.
You can transform signals using .derive():
const count = ref(0);
const message = count.derive((n) => `The count is ${n}`);
message.follow(console.log); // "The count is 0" when count changesThe .derive() method creates a new signal that updates whenever the source signal changes.
Example of chaining with .derive():
count.derive((n) => n * 2).derive((n) => `Value: ${n}`);When you need to combine multiple signals, use combine():
import { combine } from "@purifyjs/core";
const firstName = ref("John");
const lastName = ref("Doe");
// Combine signals into a single signal
const fullName = combine({ firstName, lastName }).derive(
({ firstName, lastName }) => `${firstName} ${lastName}`
);
fullName.follow(console.log); // "John Doe"
firstName.set("Jane"); // Logs "Jane Doe"The tags proxy creates HTML elements with reactive capabilities:
import { tags } from "@purifyjs/core";
// Always destructure tags for cleaner usage
const { div, button, input } = tags;
// Elements can be created with initial attributes
const container = div({ class: "container", id: "app" });
// Or configured with methods after creation
const submitButton = button()
.type("submit")
.className("my-button")
.textContent("Submit");Behind the scenes:
tagscreates custom elements that extend the native element types, enabling lifecycle hooks while preserving all native behavior.
The DOM offers two ways to configure elements:
- Attributes: Attributes on element objects (e.g.,
class,id) - Properties: Properties on element objects (e.g.,
textContent,className)
purify.js supports both approaches:
const { div, button } = tags;
// Setting via attributes object (during creation)
const div1 = div({
id: "app", // Sets the "id" attribute
class: "container", // Sets the "class" attribute
});
// Setting via property setter methods (after creation)
const div2 = div()
.id("app") // Sets the id property
.className("Hello"); // Sets the className propertyImportant distinctions:
- Use the attribute syntax for attributes:
div({ class: "x" })- Use method calls for properties:
div().textContent("x")- Both approaches are valid, but attributes must be set during element creation
Every element you create with tags is wrapped in a Builder instance:
const { div } = tags;
const element = div(); // Returns a Builder<WithLifecycle<HTMLDivElement>>
// Access the raw DOM node with $node
document.body.append(element.$node);You can also create a Builder from any existing DOM node:
import { Builder } from "@purifyjs/core";
// Works with any Node type (Element, Document, ShadowRoot, DocumentFragment, etc)
const host = new Builder(document.createElement("div"));
const shadow = new Builder(host.$node.attachShadow({ mode: "open" }));
// Use the builder to modify the node
shadow.append$(
div({ class: "container" }).textContent("Hello world"),
);
document.body.append(host.$node);purify.js uses the $ character in two important ways to distinguish special functionality:
Methods ending with $ accept signals and arrays as arguments and automatically convert them to DOM nodes recursively:
const { div, span } = tags;
const count = ref(0);
div({ class: "container" }).append$(
"Regular text", // Plain string
count, // Signal (wrapped in a container element)
[span(), div()], // Array (converted to DocumentFragment)
);What's happening: Without the
$suffix, you'd need to manually convert everything to DOM nodes usingtoChild().
The internal conversion for signals wraps them in a container element with display: contents:
// What happens internally when you append a signal
tags.div({ style: "display:contents" })
.$bind((element) =>
signal.follow(
(value) => element.replaceChildren(toChild(value)),
true,
)
);Although container elements use display: contents, they still exist in the DOM tree. This can impact CSS selectors like:
.parent > .child {} /* Won't match if there's a signal wrapper in between */
.parent :first-child {} /* Might select the wrapper instead of content */To avoid these issues with signal containers, consider:
-
Using property setters for text content where possible:
// Better than append$(textSignal) for simple text div().textContent(currentUser.derive((user) => `Welcome ${user.name}`));
-
Creating custom helper functions for specific DOM updates (see the "Helper Functions" section)
Properties beginning with $ are for custom additions to elements so they don't conflict with standard DOM properties:
const { input } = tags;
// $bind connects a function to an element's lifecycle
input().type("text").$bind((element) => {
// Runs when element connects to DOM
const listener = () => console.log(element.value);
element.addEventListener("input", listener);
// Return optional cleanup function
return () => element.removeEventListener("input", listener);
});When you include a signal in .append$() or similar methods, purify.js automatically sets up subscriptions:
const { div } = tags;
const currentUser = ref("Guest");
div({ class: "greeting" }).append$("Welcome, ", currentUser);The $ suffix removes the need for toChild():
// These two examples do the same thing:
// With $ suffix (recommended):
div({ class: "greeting" }).append$("Welcome, ", currentUser);
// Without $ suffix (more verbose):
div({ class: "greeting" }).append("Welcome, ", toChild(currentUser));If we try to inline what toChild() is doing above, under the hood, each signal is wrapped in a hidden container element (a div with
display: contents):
// What happens internally when you append a signal:
div({ class: "greeting" }).append(
"Welcome, ",
// Signal wrapping (simplified internal implementation):
tags.div({ style: "display:contents" })
.$bind((element) => currentUser.follow((value) => element.replaceChildren(value), true)).$node,
);This approach ensures:
- The signal's value is properly displayed
- Updates only affect the specific signal's wrapper element
- The wrapper element is invisible in the rendered output (thanks to
display: contents) - The element is automatically cleaned up when disconnected from the DOM
You can create reusable helper functions for common DOM manipulation patterns. By convention, these helpers are prefixed with use when
they're intended for $bind():
// Helper for replacing element children with signal values
export function useReplaceChildren<T extends Member>(signal: Sync<T>): Lifecycle.OnConnected {
return (element) =>
signal.follow(
(value) => element.replaceChildren(toChild(value)),
true,
);
}
// Using the helper
div().$bind(useReplaceChildren(currentUser.derive((user) => ["Welcome", user])));
// Helper for class toggling based on a signal
export function useToggleClass(className: string, condition: Sync<boolean>): Lifecycle.OnConnected {
return (element) =>
condition.follow((value) => {
element.classList.toggle(className, value);
}, true);
}
// Using the helper
div().$bind(useToggleClass("active", isActiveSignal));This pattern helps keep your code clean and encourages reusability.
An important restriction to know is that signals can only be used with elements that have the WithLifecycle mixin applied:
import { Builder, ref } from "@purifyjs/core";
const { div } = tags;
const count = ref(0);
// This works - elements from tags have lifecycle capabilities
div().textContent(count); // ✓ OK - Builder<WithLifecycle<HTMLDivElement>>
// This doesn't work - regular DOM elements don't have lifecycle support
const regularDiv = document.createElement("div");
new Builder(regularDiv).textContent(count); // ❌ Error: type and runtime
// To use signals with regular DOM elements, apply WithLifecycle first
import { WithLifecycle } from "@purifyjs/core";
const LifecycleDiv = WithLifecycle(HTMLDivElement);
const enhancedDiv = new LifecycleDiv();
new Builder(enhancedDiv).textContent(count); // ✓ OKWhy this limitation? purify.js needs to track when elements connect and disconnect from the DOM to properly manage signal subscriptions and cleanup.
The WithLifecycle mixin adds connection awareness to elements:
import { WithLifecycle } from "@purifyjs/core";
// Add lifecycle capabilities to any HTMLElement type
const LifecycleButton = WithLifecycle(HTMLButtonElement);
const myButton = new LifecycleButton() satisfies WithLifecycle<HTMLButtonElement>;
// The mixin is cached, so subsequent calls return the same extended class
const SameLifecycleButton = WithLifecycle(HTMLButtonElement); // Uses cached version
LifecycleButton === SameLifecycleButton; // true
// Most commonly used through the tags proxy, which applies WithLifecycle automatically
const { button } = tags;
const buttonBuilder = button() satisfies Builder<WithLifecycle<HTMLButtonElement>>;Note: The mixin only works with HTMLElement subclasses, not other Node types like Text or DocumentFragment.
Elements with lifecycle capabilities can use the $bind method:
const { div } = tags;
// The callback runs when the element connects to the DOM
div().$bind((el) => {
console.log("Element connected!");
// Optional return function runs when disconnected
return () => console.log("Element disconnected!");
});This is how purify.js implements efficient cleanup of event listeners and signal subscriptions.
purify.js doesn't have an idea of "components". It doesn't enforce any specific structure for building components like larger frameworks, but you can create reusable UI pieces using the Builder pattern:
function Counter() {
const { div, button } = tags;
const count = ref(0);
return div({ class: "counter" }).append$(
"Count: ",
count,
button()
.textContent("+")
.onclick(() => count.val++),
button()
.textContent("-")
.onclick(() => count.val--),
);
}
// Use it with a Builder
new Builder(document.body).append$(
Counter(),
);
// Or directly append the node
document.body.append(Counter().$node);
// Or change the body
document.body.replaceWith(
tags.body().append$(Counter()),
);Create two-way bindings between form controls and signals:
function useValue(value: Sync.Ref<string>): Lifecycle.OnConnected<HTMLInputElement> {
return (element) => {
const abortController = new AbortController();
element.addEventListener("input", () => value.set(element.value), { signal: abortController.signal });
const unfollow = value.follow((value) => element.value = value, true);
return () => {
abortController.abort();
unfollow();
};
};
}
const { input } = tags;
function TextInput(value: Sync.Ref<string>) {
return input()
.type("text")
.$bind(useValue(value));
}Work directly with Shadow DOM for encapsulated components:
function ShadowComponent() {
const host = tags.div();
// Create Shadow DOM and Builder for it
const shadow = new Builder(
host.$node.attachShadow({ mode: "open" }),
);
// Build inside the shadow root
shadow.append$(
tags.div().textContent("I'm inside shadow DOM!"),
);
return host;
}Create standards-compliant custom elements:
import { Builder, ref, WithLifecycle } from "@purifyjs/core";
const { button } = tags;
class CounterElement extends WithLifecycle(HTMLElement) {
static {
// Define the element in the registry
customElements.define("x-counter", this);
}
#count = ref(0);
constructor() {
super();
const self = new Builder<CounterElement>(this);
self.append$(
"Count: ",
this.#count,
button()
.textContent("+")
.onclick(() => this.#count.val++),
);
}
}- Signal followers are called synchronously during updates.
- Signals only update when their value actually changes (equality check).
- If a signal has no followers, it cleans up resources automatically.
- Computed signals only recalculate when both accessed and dependent values change
- For simplicity, maximum flexibility, and control, DOM updates are decided by the user, not the library, allowing for fine-grained control over when and how the DOM is updated.
purify.js provides a minimalist yet powerful approach to building reactive UIs:
- Use signals (
ref,sync) for reactivity - Create elements with the
tagsproxy - Configure elements with the
Builderpattern - Use
$suffix methods for working with signals and arrays - Manage lifecycles with
WithLifecycleand$bind
With these core concepts mastered, you can build complex, performant UIs with minimal code and maximum flexibility.