Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

jonlaing/effex

Open more actions menu

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

257 Commits
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

Morty Proxy This is a proxified and sanitized view of the page, visit original site.