Skip to content

jonlaing/effex

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

257 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Effex

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.

Why Effex?

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.

Typed Error Handling

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.

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:

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:

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

# 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/dom re-exports everything from @effex/core, so you don't need to install core separately.

Hello World

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):

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:

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:

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:

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;

Forms

@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.

Full-Stack SSR

@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:

  • SSR + Hydration — Server renders HTML, client picks up seamlessly
  • Loaders — Fetch data server-side, serialized to client for hydration
  • Mutation handlersRoute.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 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

Examples

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

Why No JSX?

Effex uses function calls instead of JSX:

// Effex
$.div(
  { class: "container" },
  collect($.h1({}, $.of("Hello")), $.p({}, $.of(count))),
)

Why:

  1. Error type preservation — Elements have type Element<E, R>. 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:

Acknowledgments

  • Effect — The foundation. Effect's typed errors, resource management, and structured concurrency inspired this entire project.
  • Solid — Fine-grained reactivity draws direct inspiration from Solid's reactive primitives.
  • TanStack — The router API is inspired by TanStack Router.
  • effect-form — The form package's schema-first, context-based architecture was inspired by this library.

License

MIT

About

A reactive UI framework based on Effect.ts primitives

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages