A minimal web framework with TC39-Signals-inspired reactivity and templates that compile to direct DOM operations.
- 21 functions — that's the entire API
- ~5.8 kB gzipped — with AOT compilation
- No virtual DOM — signals drive DOM updates directly
- CSP-safe — no
eval, nonew Function(with the Vite plugin) - Zero runtime dependencies
- Web Components by design —
component()registers a Custom Element with Shadow DOM (see tradeoffs in the package README) - Race-safe async — first-class
resource()with built-in cancellation, retry, polling, debouncing, and SWR by default - Server-side rendering —
renderToString+hydrate()ship with Declarative Shadow DOM and resource-aware SSR via the@purityjs/ssrpackage
The signals implementation is a custom push-pull graph inspired by the TC39 Signals proposal (Stage 1). It is not a polyfill or a binding to a native engine API — no engine ships TC39 Signals yet.
npx @purityjs/cli my-app # client-only
npx @purityjs/cli my-app --ssr # SSR + hydration
cd my-app
npm install
npm run dev| Package | Description | Docs |
|---|---|---|
@purityjs/core |
The framework — 21 functions | README |
@purityjs/ssr |
renderToString + renderToStream + DSD + resource awaiting |
README |
@purityjs/vite-plugin |
AOT template compilation (client + SSR) | README |
@purityjs/cli |
Project scaffolding (--ssr flag available) |
README |
import { state, compute, html, css, component, mount } from '@purityjs/core';
component('p-counter', () => {
const count = state(0);
const doubled = compute(() => count() * 2);
css`
button {
padding: 0.5rem 1rem;
}
`;
return html`
<p>${() => count()} (x2: ${() => doubled()})</p>
<button @click=${() => count((v) => v + 1)}>+1</button>
`;
});
mount(() => html`<p-counter></p-counter>`, document.getElementById('app')!);import { state, resource, lazyResource, debounced } from '@purityjs/core';
const id = state(1);
// Reactive resource with retry and polling baked in
const user = resource(
() => id(),
(id, { signal }) => fetch(`/u/${id}`, { signal }).then((r) => r.json()),
{ retry: 3, pollInterval: 30_000 },
);
user(); // current data (tracked) — preserved across refetches (SWR)
user.loading(); // boolean (tracked)
user.error(); // unknown (tracked)
user.refresh(); // re-fetch with the same deps
user.mutate(v); // optimistic update
user.dispose(); // tear down (or auto-cleans on component unmount)
// Imperative form for mutations / button-triggered fetches
const save = lazyResource((data: SaveArgs, { signal }) =>
fetch('/save', { method: 'POST', body: JSON.stringify(data), signal }),
);
save.fetch({ name: 'x' });
// Debounce a signal before driving a resource
const search = state('');
const query = debounced(search, 300);
const results = resource(
() => query() || null,
(q, { signal }) => fetch(`/search?q=${q}`, { signal }).then((r) => r.json()),
);Stale requests are aborted automatically when id changes or the component
unmounts. Out-of-order resolutions are dropped via a monotonic run counter.
Retries honor the abort signal — a dep change cancels mid-backoff.
// entry.server.ts
import { renderToString } from '@purityjs/ssr';
import { App } from './app.ts';
export const render = (_url: string) => renderToString(App);
// entry.client.ts
import { hydrate } from '@purityjs/core';
import { App } from './app.ts';
hydrate(document.getElementById('app')!, App);The same component code runs on Node and in the browser. Custom elements
ship as Declarative Shadow DOM (<template shadowrootmode="open">)
so the browser parses a real shadow tree before any JS loads. Resources
created during render are awaited; the resolved values are embedded as a
JSON payload that hydrate() reads to skip the first refetch.
Scaffold with npx @purityjs/cli my-app --ssr or see
examples/ssr for a working setup.
See each package README for full API documentation.
| Purity | SolidJS | Svelte 5 | Vue Vapor | |
|---|---|---|---|---|
| Approach | TC39-Signals-inspired + compiled templates | Custom signals + compiled JSX | Runes + full compiler | Proxy reactivity + compiler |
| Virtual DOM | No | No | No | No |
| **Bundle (gz)**¹ | 5.8 kB | ~7 kB | ~2 kB + generated | ~16 kB (beta³) |
| Custom Elements | Native | Optional | Optional | Optional |
| Shadow DOM | Default | Via custom elements | Via custom elements | Via custom elements |
| Two-way binding | ::prop |
Manual | bind: |
v-model |
| Async data² | resource() reactive primitive |
createResource |
{#await} template |
Userland |
| Dependencies | 0 | 0 | 0 | 0 |
¹ Purity measured locally with vite build on this branch; SolidJS / Svelte / Vue Vapor are each project's published runtime sizes — verify against bundlephobia for your specific imports.
² "Reactive primitive" means a tracked accessor with loading/error/refresh/mutate. Svelte's {#await} is a template-level control flow over a promise (no reactive resource handle). Vue Vapor has <Suspense> as a coordination boundary but no resource primitive in core.
³ Vue Vapor mode is in beta as of Vue 3.6 (May 2026); size and capabilities may change.
Runtime benchmarks — automated in headless Chromium across 18 scenarios: koalafacts.github.io/Purity
Live demo — a polling dashboard built end-to-end on Purity (state, compute, resource with retry+pollInterval, lazyResource, debounced, each, mount): koalafacts.github.io/Purity/dashboard (source)
Pre-1.0 (0.1.0). The API may break between minor versions until 1.0.
There is no public versioning policy yet, and we don't know of any production
users. If you ship Purity to users, please open an issue so we can keep your
use case in mind for the breaking-change discussions.
Knowing what's missing matters more than what's there. As of 0.1.0:
- SSR is MVP-quality.
renderToString+hydrate()ship with Declarative Shadow DOM and resource-aware two-pass rendering, but hydration is currently lossy: the SSR DOM is discarded and the component is re-rendered fresh on the client. Matching content produces an invisible flash; mismatches produce a visible jump. The hydration markers (<!--[--><!--]-->) are emitted in preparation for a follow-up that preserves the existing DOM. Named / scoped slot SSR and streaming output are not yet implemented. - No router. Not on the roadmap. Bring your own (the History API is straightforward to use directly).
- No devtools panel. Signal-graph inspection happens via
console.logtoday. A browser extension is being considered for post-1.0; not committed. - No production track record. Pre-1.0; we know of zero production deployments. Treat as a serious side-project, not a battle-tested tool.
- No accessibility audit. Shadow DOM defaults have a11y
implications (ARIA across boundaries, focus delegation). See
docs/accessibility.mdfor working patterns; nothing in the framework has been audited at scale.
Long-form guides live in /docs:
- TypeScript guide
- Why Shadow DOM by default
- Accessibility under Shadow DOM
- Migration cheatsheet (React / SolidJS / Vue / Svelte → Purity)
npm test --workspaces # all tests
npm run check # format check + lint (oxfmt + oxlint)
npm run check:fix # auto-fixMIT