createHook
createHook creates a reactive state container backed by an ES6 Proxy. When you mutate a property on the returned object, any DOM nodes bound to that property automatically update.
Signature
function createHook<T extends Record<string, any>>(initial: T): Hook<T>- initial — The initial state object.
- Returns — A proxied object where properties can be read/written directly. Properties read through the
$prefix return reactive references.
Basic Usage
const state = createHook({ name: "World" });
// Read normally
console.log(state.name); // "World"
// Write normally — triggers updates
state.name = "Croft";Reactive References ($)
Properties accessed with a $ prefix return a reactive reference — a special object that, when placed into a template, creates a live binding:
const state = createHook({ name: "World" });
// Creates a reactive text node
html`<div>Hello, ${state.$name}!</div>`;
// When you update the value, the DOM updates automatically
state.name = "Croft"; // DOM updates to: Hello, Croft!TypeScript Support
createHook preserves your types:
interface User {
name: string;
age: number;
tags: string[];
}
const state = createHook<User>({ name: "Alice", age: 30, tags: ["dev"] });
// Fully typed
state.name; // string
state.$tags; // HookRef<string[]>
state.$age.toFixed(0); // method forwarding worksArrays
Arrays work naturally with reactive references:
const state = createHook({ items: ["a", "b", "c"] });
// Use in templates
html`
<ul>
${state.$items.map((item) => html`<li>${item}</li>`)}
</ul>
`;
// Mutate — DOM updates
state.items = [...state.items, "d"];Immutable Updates
Array methods like .push() mutate in-place and won't trigger reactivity. Always use immutable updates (state.items = [...state.items, newItem]) to ensure the proxy detects the change.
Nested Objects
Nested objects are also proxied:
const state = createHook({
user: { name: "Alice", settings: { theme: "dark" } },
});
// Reactive reference to nested property
html`<div>${state.$user.settings.$theme}</div>`;
state.user = { ...state.user, settings: { theme: "light" } };However, for deeply nested reactivity, you may want to flatten your state or create separate hooks for different concerns.
Traps / Transforms
A trap (or transform) is a callback you pass to a $ reference to derive a new value. Unlike JavaScript operators (===, >, .length), traps create a reactive chain — they re-evaluate whenever the source value changes.
const state = createHook({ todos: [], filter: "all" });
// ❌ Not reactive — evaluated immediately
state.$filter === "all" // true/false at render time only
state.$todos.length // function reference, breaks in templates
// ✅ Reactive — trap re-evaluates on every change
state.$filter((f) => f === "all") // boolean
state.$todos((todos) => todos.filter(t => !t.completed)) // filtered array
state.$todos((todos) => todos.filter(t => !t.completed).length) // countWith plain state access
The trap callback receives a plain snapshot of all hook properties as the second argument:
const state = createHook({ todos: [], filter: "all" });
// Derive filtered todos from both filter and todos
html`
<ul>
${state.$filter((filter, snapshot) => {
// snapshot is a plain object with ALL current hook values
// snapshot.todos has the current todos array
// NOTE: snapshot is NOT reactive — use it only for reading
if (filter === "all") return snapshot.todos;
return snapshot.todos.filter(t =>
filter === "active" ? !t.completed : t.completed
);
}).map(todo => html`<li>${todo.title}</li>`)}
</ul>
`;Method forwarding
Method forwarding is another form of transform. When you call a method on a $ reference, it chains into the transform pipeline:
state.$tags.reverse() // reactive: value => value.reverse()
state.$tags.map(t => t.upperCase) // reactive: value => value.map(...)
state.$value.trim().toUpperCase() // reactive: value => value.trim().toUpperCase()This works for any method that takes arguments. Property-only accesses like .length are not callable — use a trap instead.