https://github.com/jonlaing/effex
A reactive UI framework based on Effect.ts primitives
https://github.com/jonlaing/effex
Last synced: 4 months ago
JSON representation
A reactive UI framework based on Effect.ts primitives
- Host: GitHub
- URL: https://github.com/jonlaing/effex
- Owner: jonlaing
- Created: 2025-12-07T22:48:29.000Z (8 months ago)
- Default Branch: main
- Last Pushed: 2026-03-12T06:17:08.000Z (5 months ago)
- Last Synced: 2026-03-12T11:37:10.264Z (4 months ago)
- Language: TypeScript
- Size: 3.77 MB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README

A reactive UI framework built on [Effect](https://effect.website/). Effex provides a declarative way to build web interfaces with fine-grained reactivity, automatic cleanup, and full type safety.
## Why Effex?
Effex brings the power of [Effect](https://effect.website/) to frontend development. If you're building with Effect, this is a UI framework that speaks the same language.
### Typed Error Handling
Every element has type `Element` where `E` is the error channel. Errors propagate through the component tree, and you **must** handle them before mounting:
```ts
// 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.
### Fine-Grained Reactivity
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:
```ts
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
});
```
### Automatic Resource Cleanup
Effex uses Effect's scope system. Subscriptions, timers, and other resources are automatically cleaned up when components unmount:
```ts
yield* eventSource.pipe(
Stream.runForEach(handler),
Effect.forkIn(scope), // Cleaned up when scope closes
);
```
### The Effect Ecosystem
Effex gives you access to Effect's entire ecosystem:
- **Schema** — Runtime validation with static types
- **Streams** — Reactive data flows
- **Services** — Dependency injection via Effect's context system
- **Retry/timeout** — Built-in resilience patterns
- **Structured concurrency** — Fork, join, and race without footguns
## Quick Start
```bash
# Create a new project
pnpm create effex my-app
cd my-app
pnpm install
pnpm dev
```
Or install packages individually:
```bash
# 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/dom` re-exports everything from `@effex/core`, so you don't need to install core separately.
### Hello World
```ts
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")!);
}),
);
```
## Reactive Primitives
Effex's reactivity layer lives in `@effex/core` (re-exported by `@effex/dom`):
```ts
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());
```
## DOM & Control Flow
The `@effex/dom` package provides element constructors and reactive control flow:
```ts
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")),
});
```
## Routing
`@effex/router` provides type-safe routing with the builder pattern:
```ts
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"));
```
### Loaders & Mutation Handlers
Routes can define server-side data loading and mutations when used with `@effex/platform`:
```ts
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`:
```ts
const { data, loaderPath, actions } = yield* RouteDataContext;
```
## Forms
`@effex/form` provides schema-validated forms with reactive field state:
```ts
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.
## Full-Stack SSR
`@effex/platform` bridges Effex with `@effect/platform`'s HTTP server for server-side rendering:
```ts
// 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),
);
```
```ts
// client.ts
import { hydrate } from "@effex/dom/hydrate";
import { Platform } from "@effex/platform";
hydrate(App(), document.getElementById("root")!, {
layers: Platform.makeClientLayer(router),
});
```
Key features:
- **SSR + Hydration** — Server renders HTML, client picks up seamlessly
- **Loaders** — Fetch data server-side, serialized to client for hydration
- **Mutation handlers** — `Route.post/put/delete` execute server-side, return JSON
- **Data requests** — Client navigations fetch data via `?_data=1` without full page loads
- **Redirects** — Throw `RedirectError` from loaders for server-side redirects
- **HttpApi composition** — Mount Effect's HttpApi alongside Effex pages on a single server
## Packages
| Package | Description |
|---------|-------------|
| [`@effex/core`](./packages/core) | Reactive primitives: Signal, Readable, Ref, Signal.Array/Map/Struct, AsyncCache |
| [`@effex/dom`](./packages/dom) | DOM rendering, elements, control flow, animation, mount/hydrate |
| [`@effex/router`](./packages/router) | Type-safe routing with loaders, mutation handlers, and Outlet |
| [`@effex/form`](./packages/form) | Schema-validated forms with reactive field state |
| [`@effex/platform`](./packages/platform) | Server-side rendering, hydration, and data loading |
| [`@effex/vite-plugin`](./packages/vite-plugin) | Vite plugin: SSR dev server + server-code stripping |
| [`create-effex`](./packages/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
## Examples
| Example | Description |
|---------|-------------|
| [`twitter`](./examples/twitter) | Full-stack SSR app with loaders, mutations, and caching |
| [`kanban`](./examples/kanban) | Kanban board with drag-and-drop and forms |
| [`todo-app`](./examples/todo-app) | Classic todo app |
| [`router-demo`](./examples/router-demo) | Router features showcase |
## Why No JSX?
Effex uses function calls instead of JSX:
```ts
// Effex
$.div(
{ class: "container" },
collect($.h1({}, $.of("Hello")), $.p({}, $.of(count))),
)
```
**Why:**
1. **Error type preservation** — Elements have type `Element`. JSX would erase this to `JSX.Element`, losing type-safe error propagation.
2. **No build configuration** — Works with any TypeScript setup. No JSX runtime, tsconfig tweaks, or bundler plugins.
3. **Explicit Effects** — Every element is an Effect that must be yielded. JSX would obscure this.
4. **Consistent syntax** — Components and elements use the same call pattern.
## Coming from Another Framework?
Migration guides with concept mapping and side-by-side examples:
- [Coming from React](./REACT-MIGRATION.md)
- [Coming from Vue](./VUE-MIGRATION.md)
- [Coming from Svelte](./SVELTE-MIGRATION.md)
## Acknowledgments
- **[Effect](https://effect.website/)** — The foundation. Effect's typed errors, resource management, and structured concurrency inspired this entire project.
- **[Solid](https://www.solidjs.com/)** — Fine-grained reactivity draws direct inspiration from Solid's reactive primitives.
- **[TanStack](https://tanstack.com/)** — The router API is inspired by TanStack Router.
- **[effect-form](https://github.com/lucas-barake/effect-form)** — The form package's schema-first, context-based architecture was inspired by this library.
## License
MIT