A reactive UI framework built on Effect. Effex provides a declarative way to build web interfaces with fine-grained reactivity, automatic cleanup, and full type safety.
Effex brings the power of Effect to frontend development. If you're building with Effect, this is a UI framework that speaks the same language.
Every element has type Element<E, R> where E is the error channel. Errors propagate through the component tree, and you must handle them before mounting:
// This won't compile — UserProfile might fail with ApiError
mount(UserProfile(), document.body); // Type error!
// Handle the error first
mount(
Boundary.error(
() => UserProfile(),
(error) => $.div({}, $.of(`Failed to load: ${error.message}`)),
),
document.body,
); // Compiles
TypeScript tells you at build time which components can fail and forces you to handle it.
Effex uses signals for reactive state. When a signal updates, only the DOM nodes that depend on it update. No virtual DOM, no diffing, no wasted work:
const Counter = () =>
Effect.gen(function* () {
const count = yield* Signal.make(0);
console.log("setup"); // Logs once, on mount
return yield* $.div({}, $.of(count)); // count changes update only this text node
});
Effex uses Effect's scope system. Subscriptions, timers, and other resources are automatically cleaned up when components unmount:
yield* eventSource.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope), // Cleaned up when scope closes
);
Effex gives you access to Effect's entire ecosystem:
# Create a new project
pnpm create effex my-app
cd my-app
pnpm install
pnpm dev
Or install packages individually:
# SPA (client-side only)
pnpm add @effex/dom @effex/router effect
# Full-stack SSR
pnpm add @effex/dom @effex/router @effex/platform @effect/platform effect
@effex/domre-exports everything from@effex/core, so you don't need to install core separately.
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(
{},
collect(
$.button({ onClick: () => count.update((n) => n - 1) }, $.of("-")),
$.span({}, $.of(count)),
$.button({ onClick: () => count.update((n) => n + 1) }, $.of("+")),
),
);
});
runApp(
Effect.gen(function* () {
yield* mount(Counter(), document.getElementById("root")!);
}),
);
Effex's reactivity layer lives in @effex/core (re-exported by @effex/dom):
import { Effect } from "effect";
import { Signal, Readable, Ref } from "@effex/dom";
// Mutable reactive state
const count = yield* Signal.make(0);
yield* count.set(5);
yield* count.update((n) => n + 1);
// Derived values (read-only, auto-tracked)
const doubled = Readable.map(count, (n) => n * 2);
const label = Readable.map(count, (n) => `Count: ${n}`);
// Reactive collections
const todos = yield* Signal.Array.make([{ text: "Learn Effex", done: false }]);
yield* todos.push({ text: "Build something", done: false });
const users = yield* Signal.Map.make(new Map([["alice", { name: "Alice" }]]));
yield* users.set("bob", { name: "Bob" });
// Reactive structs (each field is independently reactive)
const form = yield* Signal.Struct.make({ name: "", email: "" });
yield* form.name.set("Alice"); // Only updates subscribers of `name`
// Lightweight mutable refs (not reactive, no subscriptions)
const cache = yield* Ref.make(new Map());
The @effex/dom package provides element constructors and reactive control flow:
import { $, collect, each, when, matchOption, Readable } from "@effex/dom";
// Elements accept reactive attributes
$.input({
class: Readable.map(hasError, (err) => err ? "input error" : "input"),
value: name,
onInput: (e) => name.set((e.target as HTMLInputElement).value),
});
// Conditional rendering
when(isLoggedIn, {
onTrue: () => Dashboard(),
onFalse: () => LoginPage(),
});
// List rendering with keyed reconciliation
each(todos, {
key: (todo) => todo.id,
render: (todo) => TodoItem({ todo }),
});
// Option matching
matchOption(maybeUser, {
onSome: (user) => UserCard({ user }),
onNone: () => $.span({}, $.of("No user")),
});
@effex/router provides type-safe routing with the builder pattern:
import { Route, Router, Outlet, Link } from "@effex/router";
import { Schema } from "effect";
// Define routes
const HomeRoute = Route.make("/").pipe(
Route.render(() => HomePage()),
);
const UserRoute = Route.make("/users/:id").pipe(
Route.params(Schema.Struct({ id: Schema.String })),
Route.render((data) => UserPage(data)),
);
// Compose into a router
const router = Router.empty.pipe(
Router.concat(HomeRoute),
Router.concat(UserRoute),
Router.fallback(() => NotFoundPage()),
);
// Render the matched route
$.main({}, Outlet({ router }));
// Navigate with type-safe links
Link({ href: "/users/alice" }, $.of("Alice's Profile"));
Routes can define server-side data loading and mutations when used with @effex/platform:
import { Route } from "@effex/router";
import { RedirectError } from "@effex/platform";
const PostRoute = Route.make("/posts/:id").pipe(
Route.params(Schema.Struct({ id: Schema.String })),
// Loader: runs server-side with platform, client-side in SPA mode
Route.get(
({ params }) =>
Effect.gen(function* () {
const svc = yield* PostService;
return yield* svc.getPost(params.id);
}),
(post) => PostPage({ post }),
),
// Mutation handlers: server-side only (via platform)
Route.post("update", (body) =>
Effect.gen(function* () {
const svc = yield* PostService;
return yield* svc.updatePost(body);
}),
),
);
Route components access loader data and action endpoints via RouteDataContext:
const { data, loaderPath, actions } = yield* RouteDataContext;
@effex/form provides schema-validated forms with reactive field state:
import { Field, Form } from "@effex/form";
import { Schema } from "effect";
// Define the form at module level
const LoginForm = Form.make({
email: Field.make(Schema.String.pipe(Schema.nonEmptyString()), { validateOn: "blur" }),
password: Field.make(Schema.String.pipe(Schema.minLength(8)), { validateOn: "blur" }),
});
// Use in a component
LoginForm.provide(
{
defaults: { email: "", password: "" },
onSubmit: (ctx) => Effect.tryPromise(() => login(ctx.decoded)),
},
$.form(
{ class: "login" },
collect(
Effect.gen(function* () {
const email = yield* LoginForm.fields.email;
return yield* $.input({
value: email.value,
onInput: (e) => email.set((e.target as HTMLInputElement).value),
onBlur: () => email.blur(),
});
}),
// ... more fields
),
),
);
Supports leaf fields, nested structs, arrays, and maps — all with Effect Schema validation.
@effex/platform bridges Effex with @effect/platform's HTTP server for server-side rendering:
// server.ts
import { Platform } from "@effex/platform";
const effexRoutes = Platform.toHttpRoutes(router, {
app: App,
document: { title: "My App", scripts: ["/client.js"] },
});
// Compose with any @effect/platform HttpRouter
const httpApp = HttpRouter.empty.pipe(
HttpRouter.get("/api/health", HttpServerResponse.json({ ok: true })),
HttpRouter.concat(effexRoutes),
);
// client.ts
import { hydrate } from "@effex/dom/hydrate";
import { Platform } from "@effex/platform";
hydrate(App(), document.getElementById("root")!, {
layers: Platform.makeClientLayer(router),
});
Key features:
Route.post/put/delete execute server-side, return JSON?_data=1 without full page loadsRedirectError from loaders for server-side redirects| Package | Description |
|---|---|
@effex/core |
Reactive primitives: Signal, Readable, Ref, Signal.Array/Map/Struct, AsyncCache |
@effex/dom |
DOM rendering, elements, control flow, animation, mount/hydrate |
@effex/router |
Type-safe routing with loaders, mutation handlers, and Outlet |
@effex/form |
Schema-validated forms with reactive field state |
@effex/platform |
Server-side rendering, hydration, and data loading |
@effex/vite-plugin |
Vite plugin: SSR dev server + server-code stripping |
create-effex |
CLI to scaffold new projects (SPA or SSR) |
Import conventions:
@effex/dom re-exports everything from @effex/core — no need to install core separately@effex/platform does not re-export dom or router — import them directly| Example | Description |
|---|---|
twitter |
Full-stack SSR app with loaders, mutations, and caching |
kanban |
Kanban board with drag-and-drop and forms |
todo-app |
Classic todo app |
router-demo |
Router features showcase |
Effex uses function calls instead of JSX:
// Effex
$.div(
{ class: "container" },
collect($.h1({}, $.of("Hello")), $.p({}, $.of(count))),
)
Why:
Element<E, R>. JSX would erase this to JSX.Element, losing type-safe error propagation.Migration guides with concept mapping and side-by-side examples:
MIT