An open API service indexing awesome lists of open source software.

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

Awesome Lists containing this project

README

          



Effex

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