A tiny dependency-free reactive app kernel for small browser apps, embeddable widgets, progressive SPAs, and local-first browser applications.
Ity 3 keeps the original Ity goal: do useful client-side app work without a framework stack, runtime dependencies, or a large bundle. The core stays platform-native and explicit: signals, computed values, effects, tagged HTML templates, DOM rendering, Web Components, and a modern router. V3 adds a small set of app-scale primitives and optional companion modules without trying to recreate React inside the core.
signal, computed, effect, and store.resource, action, form, and formState.unsafeHTML.component().repeat().renderToString() and
hydrate().createScope(), ctx.provide(), and
ctx.inject().observeRuntime().ity/query, ity/forms, and
ity/react.Model, Collection, View, Application, and
SelectorObject.Ity 3.0.0 expands the kernel without changing its native-first design:
repeat(items, key, render) for keyed structural list rendering.hydrate() plus morph-based render() updates for progressive enhancement
and SSR-style flows.createScope() and scope-aware components and routers.observeRuntime() for signals, resources, actions, and router activity.Router.resource() and Router.action() for route-scoped async work.ity/query for cache-backed async queries and optimistic mutations.ity/forms for nested form state, field arrays, explicit form.sync(),
and richer submit controllers.ity/react for wrapping Ity custom elements inside React trees.Ity 4.0.0 is a security-hardening major release:
javascript: and vbscript: from URL-bearing
attributes and properties.srcdoc and .innerHTML now require an explicit
unsafeHTML(...) boundary so trusted markup stays deliberate.SelectorObject string insertion is now text-by-default. Use
Ity.unsafeHTML(...) if you intentionally want append, prepend, html,
before, or after to parse trusted HTML strings.renderToString() now uses the same sink protections as the
browser renderer, which removes a client/server consistency gap for unsafe
attrs.srcdoc, xlink:href, and protocol-based data:
navigation payloads.npm install ity
import Ity, { signal, computed, html, render } from "ity";
import { createQueryClient, query } from "ity/query";
import { createFormKit } from "ity/forms";
The package ships ESM, CommonJS, browser IIFE, minified IIFE, source maps, and TypeScript declarations.
ity, ity/query, and ity/forms have no runtime package dependencies.
ity/react is optional and expects react to already exist in the consuming
application.
The repository includes small focused demos plus a deeper production-style app:
repeat, scopes, runtime observation, ity/query, ity/forms, router resources/actions, component(), unsafeHTML(), and renderToString() together.Run them locally with:
npm install
npm run examples:serve
import { signal, computed, html, render } from "ity";
const count = signal(0);
const doubled = computed(() => count() * 2);
render(() => html`
<button @click=${() => count.update((n) => n + 1)}>
Count: ${count}
</button>
Doubled: ${doubled}
`, "#app");
When count changes, only the reactive render effect reruns. Dynamic text is
inserted as text, not parsed as HTML.
Signals are callable values.
const name = Ity.signal("Ada");
name(); // "Ada"
name.set("Grace"); // "Grace"
name("Hedy"); // callable setter
name.update((v) => v.toUpperCase());
name.peek(); // read without dependency tracking
computedconst first = Ity.signal("Ada");
const last = Ity.signal("Lovelace");
const full = Ity.computed(() => `${first()} ${last()}`);
full(); // "Ada Lovelace"
Computed values are lazy and cached. They invalidate when dependencies change.
effectconst stop = Ity.effect((onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
console.log("Current value", full());
});
stop();
batch and untrackIty.batch(() => {
first.set("Grace");
last.set("Hopper");
});
const snapshot = Ity.untrack(() => full());
batch coalesces dependent effects. untrack reads without subscribing the
current computation.
storeconst state = Ity.store({ name: "Ada", count: 1 });
state.name; // reactive read
state.count = 2;
state.$patch({ name: "Grace" });
state.$snapshot(); // plain object
const unsubscribe = state.$subscribe((value) => {
console.log(value);
}, { immediate: true });
store tracks object structure as well as values. Effects and subscribers that
read $snapshot() rerun when keys are added or deleted.
resourceresource() models loadable async data.
const user = Ity.resource(async ({ signal, previous, refreshId }) => {
const response = await fetch(`/api/user?refresh=${refreshId}`, { signal });
if (!response.ok) throw new Error("Failed to load user");
return response.json() as Promise<{ name: string }>;
}, {
initialValue: undefined,
keepPrevious: true,
onError(error) {
console.error(error);
}
});
Ity.render(() => Ity.html`
${user.loading() && Ity.html`
Loading...
`}
${user.error() && Ity.html`
${user.error().message}
`}
${user.data() && Ity.html`<h1>${user.data().name}</h1>`}
<button @click=${() => user.refresh()}>Refresh</button>
`, "#app");
Each refresh aborts the previous in-flight refresh. Stale completions are
ignored, failures are captured in error instead of being thrown from
refresh(), and mutate(value) can update local state optimistically.
actionaction() models async writes and other user-triggered effects.
const save = Ity.action(async (payload: { name: string }) => {
const response = await fetch("/api/user", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Save failed");
return response.json();
});
Ity.render(() => Ity.html`
<button ?disabled=${save.pending()} @click=${() => save({ name: "Ada" })}>
${save.pending() ? "Saving..." : "Save"}
</button>
`, "#app");
Actions expose data, error, pending, pendingCount, status,
submit(), run(), with(), from(), and reset().
formform() wraps action() for native forms.
const signup = Ity.form(async (data) => {
const response = await fetch("/signup", {
method: "POST",
body: data
});
if (!response.ok) throw new Error("Signup failed");
return response.json();
}, { resetOnSuccess: true });
Ity.render(() => Ity.html`
<form @submit=${signup.onSubmit}>
<input name="email" type="email" required>
<button ?disabled=${signup.pending()}>Join</button>
${signup.error() && Ity.html`
${signup.error().message}
`}
</form>
`, "#app");
For direct DOM event wiring that should stay inside the controller error model,
prefer signup.handleSubmit over signup.onSubmit.
formStateformState() adds field-level state on top of native forms.
const draft = Ity.formState({
title: "",
ownerId: "ava",
urgent: false
}, {
validators: {
title(value) {
return value.trim() ? null : "Title is required.";
}
}
});
const saveDraft = draft.submit(async (values) => {
return fetch("/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(values)
});
});
Ity.render(() => Ity.html`
<form @submit=${saveDraft.handleSubmit}>
<input bind=${draft.bind("title", { name: "taskTitle" })}>
<select bind=${draft.bind("ownerId", { type: "select" })}></select>
<label>
<input type="checkbox" bind=${draft.bind("urgent", { type: "checkbox" })}>
Urgent
</label>
${draft.errors.title && Ity.html`
${draft.errors.title}
`}
</form>
`, "#app");
formState() exposes values, initialValues, errors, touched, dirty,
valid, field(name), bind(name), set(), reset(), validate(),
markTouched(), and submit().
repeatUse repeat() when list items need keyed identity:
const tasks = Ity.signal([
{ id: "a", title: "Draft launch brief" },
{ id: "b", title: "Ship release notes" }
]);
Ity.render(() => Ity.html`
<ul>
${Ity.repeat(tasks(), (task) => task.id, (task) => Ity.html`
<li>${task.title}</li>
`)}
</ul>
`, "#app");
hydratehydrate() attaches bindings to existing markup instead of replacing it:
Ity.hydrate(() => Ity.html`
<button @click=${() => console.log("ready")}>Hydrated</button>
`, "#app");
createScope and observeRuntimeScopes let components and routers share services without a framework-wide context object. Runtime observation gives a lightweight event stream for debugging and tooling.
const scope = Ity.createScope({ name: "app" });
scope.provide("apiBase", "/api");
const stop = Ity.observeRuntime((event) => {
console.log(event.type, event.name);
});
ity/queryUse ity/query when async data should be cached and invalidated:
import { createQueryClient, query, mutation } from "ity/query";
const client = createQueryClient();
const user = query(client, ["user", "42"], async () => {
const response = await fetch("/api/users/42");
return response.json();
});
const saveUser = mutation(client, async (payload) => {
const response = await fetch("/api/users/42", {
method: "POST",
body: JSON.stringify(payload)
});
return response.json();
}, {
invalidate: [["user", "42"]]
});
ity/formsity/forms is the richer companion to core formState(). Use it when the form
needs nested paths, field arrays, or explicit syncing before structural
mutations:
import { createFormKit } from "ity/forms";
const draft = createFormKit({
title: "",
checklist: [{ label: "" }]
});
const checklist = draft.array("checklist");
ity/reactUse ity/react when Ity custom elements need to live inside a React tree:
import { wrapCustomElement } from "ity/react";
const UserCard = wrapCustomElement("user-card", {
events: {
onChoose: "choose"
}
});
ity/react is a bridge, not part of the dependency-free core. Consumers using
that entrypoint should already have react installed.
html creates a template result. Use render to mount it.
const title = Ity.signal("Dashboard");
Ity.render(() => Ity.html`
<section class=${["panel", "primary"]}>
<h1>${title}</h1>
<input .value=${title}>
<button ?disabled=${false} @click=${() => title.set("Updated")}>
Rename
</button>
</section>
`, "#app");
Supported bindings:
${value}.@click=${handler} or @click=${[handler, options]}..value=${value}.?disabled=${condition}.href=${url}, class=${["a", "b"]}, class=${{ active: true }}.style=${{ color: "red" }}.Dynamic values are safe text by default. If you intentionally want to parse a string as HTML, mark that boundary explicitly:
Ity.html`<article>${Ity.unsafeHTML(trustedHtmlString)}</article>`;
Only pass trusted content to unsafeHTML. If your application allows rich HTML
from a less trusted source, wire in your sanitizer:
const htmlPolicy = Ity.createConfig({
sanitizeHTML(value) {
return DOMPurify.sanitize(value);
}
});
Ity.render(() => Ity.html`
<article>${Ity.unsafeHTML(userProvidedHtml)}</article>
`, "#app", { config: htmlPolicy });
You can still sanitize one boundary without changing configuration:
Ity.unsafeHTML(markdownHtml, { sanitize: sanitizeMarkdownOutput });
Ity.configure({ sanitizeHTML }) still exists for process-wide setup, but
createConfig() is the better fit for multi-app pages, tests, and SSR.
Ity does not bundle a sanitizer. Sanitization policy depends on the content source and threat model, and most production apps already standardize that choice separately.
Safe bindings also block inline ha