An experimental TypeScript UI framework where Effect's <A, E, R> channels
propagate from every leaf of the view tree to the root. Forgetting to provide
a service Layer becomes a compile-time error that names the missing
service.
Why "verrex"? The name is built from the channels of an
Effect<View, E, R>— V (View — theA, which here is always theView), E (Error), R (Requirements) — plus X, because the JSX/TSX syntax it borrows adds an X too.V + E + R + Xspells verx, stylized to verrex (and the source extension is.vx).
Status: proof-of-concept. Not for production. Architecture, invariants, and per-package contracts live in AGENTS.md and the per-subsystem AGENTS.md tree.
▶ Live demo — a guided tour through each
primitive: the source on the left, the inferred Effect<View, E, R> type, and
the running component (with a reset button) on the right.
- Channels survive the tree. A
<UserPage userId="42" />whose internals callHttp.getUser(id)propagatesHttpErrorandHttp | Themeup to the root through every intervening<div>,<Fragment>, conditional, list, and component. The root must provide aLayercovering the entireR, or it fails to compile. - effect-atom's API, with
R/Eowned by the caller. State isAtom.make(x); async data isyield* atom((get) => http.getUser(get(id))), the state of a function call isyield* fn((u) => http.save(u))— the sameAtom.make/Atom.fn/AsyncResultyou know from effect-atom, minusAtom.runtime: the component's own services ride tomount, so a forgottenLayeris a compile error at the root. - Reactive values in JSX. An atom IS a value:
{count},value={prompt}. Expressions read withget(...):{get(loading) ? <Spinner /> : <Content />}— the compiler lowers it toh.reader(() => …)(anAtom.readable), one word shared with atom bodies. Noget→ the expression stays static. - Errors escalate as values.
<On value={user} Waiting={…} Success={(s) => …} NotFound={(e) => … } />renders a tagged value by tag and BUBBLES any unhandled failure — the residualEridesView<E>to the nearestCatch. Works forOption,Result,AsyncResult,Exit, your own tagged unions. Two gotchas: a non-failure tag with no arm renders nothing;Waitingwins overSuccesswhile a refetch is in flight — omitWaitingand reads.waitinginsideSuccessto keep stale data visible. - Effect-native error boundary.
<Catch Failure={(cause, reset) => fallback}>(or<Catch HttpError={(e, reset) => …}>for tag-selective — the same arm keys asOn, butCatchhandlers get(error, reset)/(cause, reset)whereOn's get(error, variant)/(variant)) recovers the failure side of a view subtree, mirroring Effect'scatch*.mountrequires every error discharged, so a forgotten boundary is a compile error that names the unhandled error — the runtime counterpart of a forgottenLayer. - Effect v4 primitives all the way down.
Atom/AtomRef/AtomRegistryfromeffect/unstable/reactivityare the reactivity layer; we don't build our own.AsyncResultis the loading/success/failure shape. - Keyed reactive lists.
<For each={todos}>{(todo) => <Row item={todo} />}</For>over anAtomRef.Collection(rows are refs, keyed by identity) or<For each={users} key={(u) => u.id}>over any array atom (rows are per-key atoms) — adding, removing, or toggling one item never tears down the others. - Custom file extension.
.vxfiles are compiled by Babel to plain TypeScript beforetscever sees them, so TypeScript's JSX type checker is never engaged — that's how channels survive instead of collapsing toJSX.Element.
git clone … verrex
cd verrex
pnpm install
pnpm dev
# open http://localhost:5173The demo is a guided tour that exercises every primitive — reactive counter,
blocking and atom-driven data fetches, dependency-driven refetch, keyed
reactive list, and per-component lifecycle — each with a reset button. It's
also deployed at m9tdev.github.io/verrex.
On Nix, nix develop drops you into a shell with Node, Corepack (for
pnpm via the packageManager field), and Chromium (with VERREX_CHROMIUM
pre-exported for the probe scripts).
pnpm add @verrex/core effect # the framework (effect is a peer dependency)
pnpm add -D @verrex/ts-plugin # editor support for .vx files (see below)@verrex/core ships its compiled dist alongside the original src with declaration
maps, so go-to-definition jumps straight into the framework's TypeScript source.
Releases are cut from conventional commits (release-please) and published to npm
with provenance. It's 0.x and experimental — expect breaking changes between
minor versions.
// Counter.vx
import { Atom } from "effect/unstable/reactivity"
import { Component } from "@verrex/core"
export const Counter = Component.make(function* () {
const count = Atom.make(0) // the default local cell
return yield* (
<div>
<button onclick={() => Atom.update(count, (n) => n + 1)}>+</button>
<span>
{" "}
{count} clicks, {get(count) * 2} half-clicks{" "}
</span>
<button onclick={() => Atom.set(count, 0)}>reset</button>
</div>
)
})
// Inferred: Effect<View, never, AtomRegistry> — the handlers' writes need the
// registry; `mount` owns one and discharges it. An atom in JSX renders live;
// `get(atom)` inside a JSX expression tracks it. (`AtomRef` is only the
// `Collection` row model — reading `.value` in JSX is not tracked.)// main.vx
import { Effect } from "effect"
import { mount } from "@verrex/core"
import { Counter } from "./Counter.vx"
// `mount` owns its AtomRegistry (created per mount, disposed with the
// mount's scope) — there is no framework layer to provide.
const program = Effect.gen(function* () {
yield* mount(<Counter />, document.getElementById("root")!)
yield* Effect.never
}).pipe(Effect.scoped)
Effect.runFork(program)Closing the scope around a mount detaches the DOM and disposes the registry together, so the UI and its reactivity always die at the same moment.
packages/
core/ one package (`@verrex/core`), subpath exports:
src/runtime/ export `@verrex/core` — View IR, h(), get, mount(), atom(), fn(), For, On, Catch
src/compiler/ export `@verrex/core/compiler` — .vx → plain TypeScript (Babel)
src/language/ export `@verrex/core/language` — Volar language plugin
src/check/ export `@verrex/core/check`, bin `verrex-check`
src/vite-plugin/ export `@verrex/core/vite` — Vite integration
src/testing/ export `@verrex/core/testing`
ts-plugin/ publishes as `@verrex/ts-plugin` — TS Language Service plugin (editor)
apps/
demo/ Guided tour — one .vx per step: Counter, UserPage, AsyncUserPage, LiveUser, SaveButton,
TypedHandlers, Clock, Todos, Lifecycle, CatchDemo, AsyncEscalate
| You import from | What you get |
|---|---|
@verrex/core |
h, get, mount, Component, atom, fn, For, On, Catch, Fragment, View |
effect |
Effect, Layer, Context.Service, Data.TaggedError, Cause, Option, Result, … |
effect/unstable/reactivity |
AtomRef, Atom, AtomRegistry, AsyncResult |
h.reader is compiler-emitted; you never write it by hand. Reactive reads
in JSX are always explicit through get(...) — the compiler wraps the
surrounding expression in a reader (an Atom.readable) — or by passing the
atom itself as a child/prop.
| Command | What it does |
|---|---|
pnpm dev |
Vite dev server with HMR on .vx files |
pnpm typecheck |
Per-package tsc --noEmit; apps/demo uses @verrex/core/check (.vx-aware) |
pnpm build |
Production build via Vite (@verrex/core/vite owns the transform) |
pnpm test |
All package suites — compiler, runtime, language, vite-plugin, testing, ts-plugin (incl. its tsserver integration probe) + @verrex/core/check |
If your app references this repo with "@verrex/core": "link:../verrex/packages/core",
the link pulls in verrex's own node_modules/effect next to your app's copy. Two
physical effect copies break Context/Layer identity silently (a service you
provided reads as missing). Dedupe in both configs:
// vite.config.ts and vitest.config.ts
export default { resolve: { dedupe: ["effect"] } }Installs from npm do not have this problem — pnpm hoists one effect.
pnpm build on the demo produces:
| Asset | Raw | Gzipped |
|---|---|---|
dist/index.html |
12.97 kB | 3.46 kB |
dist/assets/index-*.js |
132.75 kB | 44.75 kB |
The JS bundle contains: the effect runtime,
effect/unstable/reactivity (AtomRef, Atom,
AtomRegistry, AsyncResult), the verrex runtime (a few kB gzipped),
plus all eleven demo components (Counter,
UserPage, AsyncUserPage, LiveUser, SaveButton, TypedHandlers,
Clock, Todos, Lifecycle, CatchDemo, AsyncEscalate), the guided-tour
shell (a small dependency-free TSX highlighter + reactivity-flash visualizer),
and their mock services. The production bundle is exercised end-to-end by
scripts/probe-prod.mjs.
Vite serves .vx files directly through @verrex/core/vite at dev time;
type-checking goes through @verrex/core/check, which feeds .vx to tsc as virtual
TypeScript via the shared Volar language plugin. No sibling .ts files are
emitted to disk.
A TypeScript Language Service Plugin (@verrex/ts-plugin) ships with the
workspace and is wired into apps/demo/tsconfig.json's plugins array.
The plugin uses Volar's language plugin framework to provide full IDE
support for .vx files.
What works: Diagnostics, hover, go-to-definition, find-references, inlay hints, and document highlights (including JSX tag pair matching).
On GitHub, .vx files render with TSX syntax highlighting (via a
linguist-language=TSX override in .gitattributes).
" Treat .vx as TSX so your LSP attaches and treesitter highlights it
autocmd BufRead,BufNewFile *.vx setfiletype typescriptreactThat plus tsserver already configured for typescriptreact is enough.
First time opening the workspace you may want to ensure
packages/ts-plugin/dist/index.cjs exists — run pnpm install from the
repo root or pnpm --filter @verrex/ts-plugin build directly.
vtsls users need one more setting. vtsls's bundled tsserver does not
resolve tsconfig plugins from the project's node_modules, so the plugin
silently never loads and tsc reports raw-JSX errors (react/jsx-runtime,
JSX.IntrinsicElements). Point it at the workspace TypeScript (or its
plugin search path) — e.g. with nvim-lspconfig:
require("lspconfig").vtsls.setup({
settings = {
vtsls = { autoUseWorkspaceTsdk = true },
typescript = { tsserver = { pluginPaths = { "./node_modules" } } },
},
})(Same rule in VS Code: the plugin only loads under the workspace TS version — hence the instruction below.)
The plugin must be probe-able from your TypeScript install. tsserver
resolves tsconfig plugins by walking up from its own typescript.js, so
in a pnpm workspace the package has to sit at the root node_modules —
declaring @verrex/ts-plugin only in a sub-package leaves it invisible and
you get the raw-JSX errors above, with no hint why. Declare it as a root
devDependency too (or hoist it).
@verrex/ts-plugin is referenced in apps/demo/tsconfig.json. Use
"TypeScript: Select TypeScript Version → Use Workspace Version" to make
sure VS Code's TS extension picks up the plugin. .vx files get treated
as TypeScript once the plugin loads.
- AGENTS.md — architecture, per-package contracts, invariants, anti-patterns.
apps/demo/src/channels.test-d.ts— compile-time proof that channels propagate and typed props catch misuse.packages/core/src/runtime/types/Fold.test-d.ts— channel-fold conditional-type test matrix.