A reactive UI framework built on
Effect.ts
primitives.
$ pnpm create effex my-app

Fully Typesafe

Every element carries its error and dependency types. TypeScript catches unhandled failures and missing context at compile time — not in production.

Full Stack Reactivity

The same signals, components, and router work across SPAs, server-rendered apps, and static sites. One model from prototype to production.

Built on the power of Effect.ts

Structured concurrency, typed errors, dependency injection, and automatic resource cleanup — all built in. No extra libraries required.

import { Effect } from "effect";
import { $, collect, Signal, mount, runApp } from "@effex/dom";

const Counter = () =>
  Effect.gen(function* () {
    const count = yield* Signal.make(0);

    return yield* $.div(
      { class: "flex items-center gap-4" },
      collect(
        $.button(
          {
            class: "btn btn-primary",
            onClick: () => count.update((n) => n - 1),
          },
          $.of("-"),
        ),
        $.span({ class: "text-2xl tabular-nums" }, count),
        $.button(
          {
            class: "btn btn-primary",
            onClick: () => count.update((n) => n + 1),
          },
          $.of("+"),
        ),
      ),
    );
  });

// Run the app!
runApp(mount(Counter(), document.getElementById("root")!));

Why Effex?

Signals, not hooks

Signals are mutable references that track their own subscribers. Read a signal inside an element, and that element updates when the signal changes — automatically. No dependency arrays to maintain, no useCallback to remember, no stale closure bugs to chase down.

// Signals are references, not snapshots.
// No stale closures, no dependency arrays.
const name = yield* Signal.make("world");

// Use a signal directly as element content —
// the text node updates when name changes.
const greeting = yield* $.h1({}, name);

// Derived values update automatically.
const upper = Readable.map(name, (n) => n.toUpperCase());
const shout = yield* $.p({}, upper);

Errors you can see

Every element in Effex has the type Element<E, R> — where E is the error channel and R is the required context. If a component can fail, TypeScript tells you before you ship. If it needs a service, the compiler asks for it. Runtime surprises become compile-time conversations.

// This component can fail — the error type says so.
const UserProfile = (id: string): Element<HttpError, ApiClient> =>
  Effect.gen(function* () {
    const api = yield* ApiClient;
    const user = yield* api.getUser(id);
    return yield* $.div({}, $.of(user.name));
  });

// TypeScript won't let you mount this without
// handling HttpError and providing ApiClient.
// Errors are visible in the types, not hidden at runtime.

One framework, every target

Write your components once. Run them client-side as an SPA, server-render with hydration, or pre-render as a static site. The same router, the same signals, the same component model — just a different entry point.

// Same component, three targets.

// SPA — client-side only
runApp(mount(App(), root));

// SSR — server renders, client hydrates
// server:
const routes = Platform.toHttpRoutes(router, opts);
// client:
hydrate(App(), root);

// SSG — pre-render at build time
Route.static({
  paths: () => discoverPages(),
  load: ({ params }) => loadPage(params.slug),
  render: (data) => DocPage(data),
});

Get started in seconds

$ pnpm create effex my-app