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. - Reactive values in JSX expressions.
{loading.value ? <Spinner /> : <Content />}works againstloading: AtomRef<boolean>— you write.valueexplicitly and the compiler rewrites that read into a tracked one, wrapping the surrounding expression in a tracking scope so it re-renders whenloadingchanges. - Effect-native async boundary.
Await(() => http.getUser(id), { pending, onError, onSuccess })runs an effect and renders pending → success/error, folding the effect'sRinto the component (a forgottenLayeris still a compile error). The thunk auto-tracks any.valueit reads, so it refetches when they change. - Effect v4 primitives all the way down.
AtomRef/Atom/AtomRegistryfromeffect/unstable/reactivityare the reactivity layer; we don't build our own.AsyncResultis the loading/success/failure shape. - Keyed reactive lists.
{todos.value.map(item => <Row item={item} />)}is compiled to a keyed list that reconciles byAtomRefidentity — 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 Await-boundary data fetches, auto-tracking 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 { Effect } from "effect"
import { AtomRef } from "effect/unstable/reactivity"
export const Counter = Effect.fn("Counter")(function* (_props: {} = {}) {
const count = AtomRef.make(0)
return yield* (
<div>
<button onclick={() => count.update((n) => n + 1)}>+</button>
<span> {count} clicks </span>
<button onclick={() => count.set(0)}>reset</button>
</div>
)
})// main.vx
import { Effect, Layer } from "effect"
import { VerrexLive, mount } from "@verrex/core"
import { Counter } from "./Counter.vx"
const program = Effect.gen(function* () {
yield* mount(<Counter />, document.getElementById("root")!)
yield* Effect.never
}).pipe(
Effect.scoped,
Effect.provide(VerrexLive),
)
Effect.runFork(program)packages/
verrex/ one package, subpath exports:
src/runtime/ export `@verrex/core` — View IR, h(), mount(), Await(), list()
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/ Counter, UserPage, AsyncUserPage, LiveUser, Todos, Lifecycle
| You import from | What you get |
|---|---|
@verrex/core |
h, mount, Await, list, Fragment, View, VerrexLive |
effect |
Effect, Layer, Context.Service, Data.TaggedError, Cause, Option, Result, … |
effect/unstable/reactivity |
AtomRef, Atom, AtomRegistry, AsyncResult |
h.track / h.read are compiler-emitted; you generally never write them
by hand. Reactive reads are always explicit through .value — the compiler
rewrites those calls into tracked reads under the hood, and the surrounding
JSX expression is automatically wrapped in a tracking scope.
| 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 |
pnpm build on the demo produces:
| Asset | Raw | Gzipped |
|---|---|---|
dist/index.html |
8.09 kB | 2.24 kB |
dist/assets/index-*.js |
103.99 kB | 35.17 kB |
The JS bundle contains: effect@4.0.0-beta.71 runtime (~6 kB gzipped per
upstream docs), effect/unstable/reactivity (AtomRef, Atom,
AtomRegistry, AsyncResult), the verrex runtime (~600 LOC,
contributes single-digit kB), plus all six demo components (Counter,
UserPage, AsyncUserPage, LiveUser, Todos, Lifecycle), the guided-tour
shell (a small dependency-free TSX highlighter + reactivity-flash visualizer),
and their mock services. Verified interactive after build — Counter increments,
the Await boundaries load then resolve, Todos add/remove/toggle, Lifecycle's
per-row scope fires releases on row removal.
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.
@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.