@verajs/core
v0.3.1
Published
A user-friendly JavaScript framework for effortless reactive state and rendering using web components.
Maintainers
Readme
@verajs/core
The heart of VeraJS: reactive state, an effect system, template tags, and the lifecycle glue that
ties them to a custom element. 3.26 KB gzipped, no base
class, no build step required, and one dependency — @verajs/inserts, the
extension registry, which the production bundle inlines.
There is no Component to extend and no compiler to run. A VeraJS component is a custom element
that calls init() and then render(); everything reactive follows from the store it reads.
npm i @verajs/core @verajs/rendererCore does not write to the DOM itself — a renderer does, and it is a separate install. That is the
one piece of wiring VeraJS asks for: wire([renderer]), once, at your app entry.
It is also what makes the renderer replaceable — a string renderer for tests, or your own — but
that is a door, not a step. @verajs/renderer and core's html need nothing configured between
them. (The seam is real and continuously asserted — tests/foreign-renderer.test.mjs drives a
foreign renderer through it — but no alternative renderer is offered as a supported mode.)
A component, whole
import { init, createStore, render, wire, html, useEffect } from '@verajs/core';
import { renderer } from '@verajs/renderer';
wire([renderer]); // once, at your app entry, before any component defines itself
customElements.define(
'click-counter',
class extends HTMLElement {
connectedCallback() {
init(this, { mode: 'open' }); // shadow DOM; omit the second argument for light DOM
const state = createStore({ count: 0 });
useEffect(() => {
document.title = `${state.count} clicks`; // re-runs whenever what it read changes
});
render(() => html`<button @click=${() => state.count++}>Clicked ${state.count} times</button>`);
}
}
);
document.body.append(document.createElement('click-counter'));Nothing here declares a dependency. render and useEffect subscribe to whatever they read while
they run, so a write to state.count schedules exactly the work that read it.
Props — what a parent passes in
A parent binds properties; the component reads them off this. Nothing is declared on either
side — no static properties, no props argument to init():
import { init, render, wire, html } from '@verajs/core';
import { renderer, renderInto } from '@verajs/renderer';
import { props } from '@verajs/renderer/spread';
wire([renderer]);
customElements.define(
'order-summary',
class extends HTMLElement {
connectedCallback() {
init(this, { mode: 'open' });
render(() => html`<p>${this.customer} — ${this.items.length} items</p>`);
}
}
);
renderInto(
html`<order-summary ${props({ customer: 'Ada', items: [{ sku: 'a' }, { sku: 'b' }] })}></order-summary>`,
document.body
);init() adopts what the parent's property bindings delivered: each bound key becomes a
store-backed accessor on the element, so a read in a render is tracked and the parent's next
commit re-renders — reactivity in both directions, with values arriving by identity (an
array, a Date, a store or a ref() passed down stays itself, and stays live). Three things
worth knowing:
- Values come from property bindings —
.date=${…}in a template,props({ date })in either surface, or a sigil-keyedspread()bag. An attribute (date="…") is a string in markup, not a prop; the renderer README draws the full property/attribute line. - Lazy modules are safe. A value bound before the component's module ran would be destroyed
by class-field initializers at upgrade; the renderer records it and
init()re-applies — a bound value outranks a class default, in both field spellings (item;anditem = default). - A prop's default belongs where the prop is born: bind
props({ date: date ?? defaultDate })in the parent, or readthis.date ?? defaultDatein the component — a class field initializer is not a default for a bound key, because bound always wins. - A class that declares its own accessors keeps them. A
get item()/set item()pair receives bound values through the setter — adoption never shadows it — which also means the pair owns its reactivity: back it with your own store (set item(v) { this.#state.item = v }) and later commits re-render exactly as adopted props do. A getter with no setter refuses the binding by name in development instead of silently losing the value. - SSR delivers them too. Under
@verajs/ssr, a property bound on a rendered component tag reaches that child's server render by identity, so the server's output comes from the same data the client render gets.
State
| | |
| --- | --- |
| createStore(obj) | deep reactive proxy — nested objects are tracked too |
| ref(value) | a deep reactive box for a single value, read and written as .value |
| shallowRef(value) | .value is tracked; the contents are not proxied |
| untrack(fn) | read current state without subscribing to it |
| deps(...values) | touch values explicitly, to register them as dependencies |
| store._delete() | sever every subscription for an object store at once |
Reactive Map, Set, WeakMap and WeakSet need @verajs/store/collections: put one in a
store, wire that, and mutating methods notify like any other write. Without it core says so the first
time one is read.
import { createStore, ref, shallowRef, untrack, deps } from '@verajs/core';
const state = createStore({ filter: 'all', rows: [] });
const focus = ref(null); // read and written as focus.value, deeply tracked
const frame = shallowRef(new Float32Array(64)); // .value tracked; the contents deliberately not
const total = () => untrack(() => state.rows.length); // read without subscribing
deps(state.filter); // subscribe explicitly, without using the value yetOne more, for servers: setStaticStores(true) makes every store created from then on a plain
object — no proxy, no tracking, reads at raw property speed. It exists for @verajs/ssr, which
turns it on around a render that declared itself static; in a browser it would give you a
framework that never updates (development throws on any write to such a store to say so).
import { setStaticStores } from '@verajs/core';
setStaticStores(true); // server-side, around a static render — never in a browserWhat "deep" reaches, and what it does not
A store proxies plain objects, arrays, class instances, Object.create(null) objects, and the four
collections. Everything else is handed back exactly as it was put in:
Date, RegExp, Promise, Error, URL, URLSearchParams, typed arrays, ArrayBuffer,
DataView, functions and DOM nodes.
That is deliberate, and it is the same reason collection methods have to be re-bound: these types
carry state in internal slots rather than in properties, so a proxy cannot see a change and in
several cases cannot even be called on one. Reading state.when gives you the real Date, and
state.when.setHours(9) changes it — but nothing re-renders, because no property was written.
Replace them instead of mutating them, which is what makes the change visible:
state.when = new Date(state.when.setHours(9)); // a write to `when`, so it renders
state.pixels = new Uint8Array(next); // not state.pixels[0] = …There is no warning for this. A Date read to format it is far more common than a Date read to
mutate it, so a warning would be noise on the ordinary case — which is why it is written down here
instead.
A frozen source object stays frozen
A store is a proxy, and a proxy has to respect its target's rules. createStore(Object.freeze(…))
reads fine and throws on any write, because JavaScript says the object is not writable and no
amount of proxying changes that. The same goes for a sealed object gaining a new key, an object
under Object.preventExtensions, a property defined writable: false, and a getter with no setter —
and it applies to a frozen object nested inside an ordinary store, which is the way it usually turns
up: a constants table sitting in state.
Everything that is not forbidden works, which is the larger half. Sealed objects take writes to
existing keys, setters run with this bound through the proxy, class instances keep their prototype
getters, Object.create(null) objects and symbol keys both round-trip.
In development the error names the rule that refused — "the object is frozen, so n cannot be
changed". In production you get the engine's own TypeError: 'set' on proxy: trap returned falsish,
which is a message about the proxy's internals; the development build exists to tell you what it
actually means. It throws either way.
Adding and removing keys counts as a change. A component that enumerates — Object.keys,
for…in, { ...state.filters }, JSON.stringify, or key in state.form — depends on the set of
keys rather than on any one of them, and hears about a key arriving or leaving. That is what makes
state.byId[newId] = row and Object.assign(state.filters, patch) render, and it is checked as a
matrix: every container kind crossed with every way of mutating it, against the data itself
(tests/core-reactivity-matrix.test.mjs).
Use shallowRef for list data. Putting 1 000 row objects through createStore proxies every one
of them, and reading every field back through those proxies measures 20–30× the cost of the same
read on a plain array (three runs, 2026-09-05 — an earlier pass of this doc said 60×, which the
store has since outgrown). When rows are replaced rather than mutated — which is the usual case —
shallowRef is the right tool.
Effects
| | Runs | Batching |
| --- | --- | --- |
| useLayoutEffect | before render | coalesced, microtask |
| useEffect | after render | coalesced, animation frame |
| useSyncEffect | immediately on every change | not batched |
All three take (callback, element?) and treat a returned function as cleanup — run before the next
pass, and on element removal. No disconnectedCallback is needed for it; if the component has
one of its own, it still runs first.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
});
useLayoutEffect(() => {
// runs before the render pass commits — measure here, and writes cannot cause a visible flash
height = list.getBoundingClientRect().height;
});The difference between coalesced and sync is what they observe:
state.n = 1; state.n = 2; state.n = 3;
// useEffect → one run, sees 3
// useSyncEffect → three runs, sees 1, 2 and 3useSyncEffect can infinite-loop if it unconditionally writes state it also reads. Guard the
write, or use useEffect. In development the recursion is stopped and named at depth 50.
useEffect and a template can loop too, and there the loop is real but not always a mistake: the
default scheduler is an animation frame, so an effect that writes what it reads simply runs once per
frame — which is also how you write an animation. So development warns and does not stop it,
after 50 consecutive frames in which the pass fed itself:
[vera] useEffect has re-run for 50 consecutive frames because it writes state it also reads …A write that lands outside the pass — from your own requestAnimationFrame, a timer, an event —
never trips it at any threshold, because the count resets on the first pass that does not feed
itself. Only a pass whose own body writes what it reads climbs. If that is deliberate, say so:
init(this);
allowRenderLoop(this); // an animation: one store write per frame, on purpose
useEffect(() => { state.t = state.t + 1 });allowRenderLoop(element) silences the warning for that component, and is a no-op in production —
where none of this exists.
Every callback receives a signal describing the change: signal.prop, signal.value,
signal.prevValue, and on coalesced runs signal.changed — a Map of every property in the batch,
holding its value at the start and at the end.
Rendering
| | |
| --- | --- |
| init(element, shadowProps?) | call first in connectedCallback. { mode: 'open' } for shadow DOM — see ARIA and the shadow boundary |
| render(template?, ...args) | draw, and commit the setup. See below |
| html | the template tag. @verajs/renderer takes what it produces with no configuration |
| svg / mathml | for content inside <svg> / <math> |
| css | for static styles, with @verajs/styles |
| mount() | commit the setup for a component that draws nothing |
| useRender(template, element, ...args) | the lower-level half of render, for driving a render outside the setup window |
| wire([renderer]) | choose what writes to the DOM |
| setRenderScheduler(fn) | defaults to requestAnimationFrame; pass microtask for Lit/Vue-style timing |
| setHtml / setCss | swap the template tags |
import { init, mount, useRender, useEffect, mathml, html,
setRenderScheduler, microtask, setHtml, setCss } from '@verajs/core';
class TickerLogger extends HTMLElement {
connectedCallback() {
init(this); // light DOM: no second argument
useEffect(() => console.log(state.tick));
mount(); // commits the setup — this component draws nothing
}
}
const formula = html`<math>${mathml`<mi>x</mi><mo>=</mo><mn>${x}</mn>`}</math>`;
useRender(() => html`<p>${state.n}</p>`, element); // re-declare a render OUTSIDE the setup window
setRenderScheduler(microtask); // Lit/Vue-style timing instead of requestAnimationFrame
setHtml(myHtml); setCss(myCss); // swap the template tags a whole app resolves throughinit() opens a component's setup and one of two calls closes it. mount() commits: it runs the
first pass of every hook registered since init() and clears the instance. render(template) is
exactly useRender(template) followed by that same commit — a compound over the base operation, not
a second way to do the same thing, which is why a component only ever calls one of them.
Use mount() when a component has no markup of its own. Hooks that are never committed never run:
no error, no render, an effect that simply does not happen — so in development a component that
finishes connectedCallback without reaching either call warns and names both.
Setup is one synchronous block, which matters for async connectedCallback(). Only one component
is being set up at a time, so a second component's init() takes the slot from the first — and an
await between init() and render() hands it over. One component alone is fine; two on a page,
each fetching, and whichever resumes second renders nothing. Await before init():
async connectedCallback() {
const data = await fetch(this.dataset.url).then((r) => r.json()); // await first
init(this, { mode: 'open' }); // then set up, synchronously
const state = createStore({ data });
render(() => html`<p>${state.data.title}</p>`);
}Server-side this is already handled for you: renderToStringAsync awaits connectedCallback.
Taking input from an attribute. Attributes are the other half of how a web component receives anything — Props is the half that carries values, and an attribute carries a string that is visible in markup, which makes it the right channel for CSS hooks, static markup and anything a person may write by hand in HTML. Reach for a prop for data (objects, arrays, stores, dates) and an attribute for the rest.
Unlike props, the wiring here is yours: write the new value into a store the template reads. The one
sharp edge is the platform's ordering — attributeChangedCallback runs before connectedCallback
for any attribute already in the markup, so the store does not exist yet on that first call. Guard
it, and read the initial value in connectedCallback:
static observedAttributes = ['label'];
attributeChangedCallback(name, previous, value) {
if (!this.state) return; // upgrade: setup has not run yet
this.state.label = value;
}
connectedCallback() {
init(this, { mode: 'open' });
this.state = createStore({ label: this.getAttribute('label') }); // the initial value
render(() => html`<p>${this.state.label}</p>`);
}Without the guard the component still renders, because a custom-element reaction that throws is
reported rather than rethrown — so the cost is an uncaught TypeError in the console of every page
using one, and nothing here can warn about it.
svg and mathml are not stylistic. A namespace is decided when markup is parsed and cannot be
fixed afterwards, so html alone produces an HTMLUnknownElement named circle — which parses
fine and never draws anything.
render(() => html`
<svg viewBox="0 0 10 10">
${svg`<circle cx=${x} cy=${y} r=${r} fill=${color} />`}
</svg>
`);Extending it
Core dispatches seven extension points and knows nothing about what is registered on them.
Renderers, autoloaders, static styles adoption, reactive collections, error boundaries and write
batching are all built this way, outside core, on the same public surface you have.
| | |
| --- | --- |
| wire({ on: name, fn: callback, priority: priority }) | register on an extension point — priority is required |
| inserts | the registry itself |
| createHook({ callback, priority, element? }) | build your own hook type |
The points are 'render', 'init', 'proxy-handler' (a store read), 'set-handler' (a store
write — return false to hold the default propagation back), 'error' (a hook threw),
'collection' (a Map/Set method read in a store — how @verajs/store/collections
attaches) and 'value' (a child-position value the renderer has no built-in answer for).
@verajs/inserts documents each one, with signatures.
import { wire, inserts, createHook } from '@verajs/core';
wire({ on: 'error', fn: (error, element) => report(error, element.localName), priority: 50 });
const errorChain = inserts.get('error'); // the registry itself: name -> ordered chain
createHook({ // your own hook type, scheduled like the rest
callback: (change, first) => { if (!first) log(change); },
priority: 60, // after render (50), before useEffect (75)
});Take wire from @verajs/core, not from @verajs/inserts. A production bundle inlines the
registry, so registering through a separately imported copy writes to a map core never reads — it
works in development and silently does nothing in production.
ARIA and the shadow boundary
Every ID-based ARIA relationship resolves within a single tree, so a shadow root breaks it
silently. aria-labelledby, aria-describedby and <label for> all match by ID, and IDs do not
cross a shadow boundary — there is no error and no warning, just an element with no accessible name.
Verified in Chromium, Firefox and WebKit (tests/browser/aria-shadow-boundary.test.js); it is the
platform's rule, not this framework's.
// Broken: the label is in the page, the input is in the shadow root.
<label for="email">Email</label>
<my-field></my-field> // init(this, { mode: 'open' }); render(() => html`<input id="email">`)Three ways through, in the order worth reaching for:
Keep the relationship inside one root. Render the label and the control in the same template. This is the common case and needs nothing special.
Put the ARIA on the host with
ElementInternals. The host lives in the outer tree, so a role and an accessible name set there are visible to the page and need no ID at all:connectedCallback() { this._internals ??= this.attachInternals(); this._internals.role = 'button'; this._internals.ariaLabel = 'Save'; init(this, { mode: 'open' }); }Use light DOM — omit the second argument to
init— when a component's whole job is to participate in relationships the page owns. Style isolation is what you give up;static stylesstill works, hoisted once per class.
delegatesFocus: true is the related shadow option: it makes the host focusable and forwards focus
to the first focusable child, which is what a custom control usually wants.
Two things that will bite you
Custom-element fields must be declared in TypeScript. At ES2022 a class field is a definition,
not an assignment: item?: Item emits item;, which runs during element upgrade and overwrites
whatever a parent already bound there. Write declare item?: Item — it emits nothing, and the
binding survives. In development the renderer warns when it observes this happening.
Prefer a stable template shape over swapping subtrees. Rendering the same elements every pass
and toggling ?hidden keeps template identity, so values update in place instead of the subtree
being torn down and rebuilt:
// fragile
html`<section>${items.length ? html`<ul>${rows}</ul>` : html`<p>empty</p>`}</section>`;
// preferred
html`<section>
<ul ?hidden=${!items.length}>${rows}</ul>
<p ?hidden=${items.length > 0}>empty</p>
</section>`;The rest
The complete API reference lives in the repository's llms.txt — written to be
pasted into an AI context window, and just as readable by people. It carries the full export list,
the buildless CDN and JSX recipes, what VeraJS deliberately does not support, and the mistakes that
come up most.
License
MIT
