$pnpm create stax-ui my-app

Frontend
Correctness

Robust utilities for building serious web applications

Probably not the quickest way to build a todo app, but the right way to build a production-ready app that solves real-world problems reliably.

Stax gives you reactive state without the ceremony. Signals are mutable references that track their own subscribers — read one inside an element, and that element updates when the signal changes. No dependency arrays, no memoization hooks, no stale-closure bugs waiting to bite you three renders later.

Every component has the type Element<E, R> — an error channel and a dependency channel. If a component can fail, TypeScript tells you before you ship. If it needs a service, the compiler asks for it. Runtime surprises become compile-time conversations.

Write your components once. Run them as an SPA, server-render with hydration, or pre-render to static HTML. The same signals, the same router, the same component model — the entry point is the only thing that changes.

Stax in
Real Life

A small todo app, split across three files. State hydrates through an Effect Context — the component doesn't know whether it's talking to localStorage or an in-memory default; the Layer decides.

Todo App

import { Effect } from "effect";
import { Plus } from "lucide-static";

import {
  $,
  Animation,
  each,
  EventHandler,
  Readable,
  Signal,
  SignalArray,
  when,
} from "@stax-ui/dom";

import { Storage } from "./Storage.js";
import { TodoItem, type Todo } from "./TodoItem.js";

const DEFAULT_TODOS: readonly Todo[] = [
  { id: "1", text: "Read the docs", done: true },
  { id: "2", text: "Build something small", done: false },
  { id: "3", text: "Wire up a real service via Effect Context", done: false },
];

export const TodoApp = () =>
  Effect.gen(function* () {
    const storage = yield* Storage;

    const isLoading = storage.isLoading;
    // reactive collection, persisted to localStorage
    const todos: SignalArray<Todo> = yield* storage.persistArray<Todo>(
      "stax-todos",
      DEFAULT_TODOS,
    );

    const draft = yield* Signal.make("");

    const submit: EventHandler<SubmitEvent> = (e) =>
      Effect.gen(function* () {
        yield* Effect.sync(() => e.preventDefault());
        const text = (yield* draft.get).trim();
        if (!text) return;
        yield* todos.push({
          id: crypto.randomUUID(),
          text,
          done: false,
        });
        yield* draft.set("");
      });

    const toggle = (id: string) =>
      Effect.gen(function* () {
        const list = yield* todos.get;
        const index = list.findIndex((t) => t.id === id);
        if (index === -1) return;

        yield* todos
          .modifyAt(index, (t) => ({ ...t, done: !t.done }))
          .pipe(Effect.orDie);
      });

    const remove = (id: string) =>
      Effect.gen(function* () {
        const list = yield* todos.get;
        const index = list.findIndex((t) => t.id === id);
        if (index === -1) return;
        yield* todos.removeAt(index);
      });

    // Done items sink to the bottom of the visible list.
    const orderedTodos = Readable.map(todos, (list) =>
      [...list].sort((a, b) => Number(a.done) - Number(b.done)),
    );

    // This runs only once on mount. State changes from Readables (SignalArray, Signal)
    // will trigger surgical DOM maniuplations rather than full re-renders.
    return yield* $.div(
      { class: "flex flex-col gap-3 w-full max-w-sm" },
      $.form(
        {
          class: "flex items-center gap-2",
          onSubmit: submit,
        },
        $.input({
          type: "text",
          placeholder: "What needs doing?",
          class: [
            "flex-1 px-3 py-2 rounded-md bg-base-200 border",
            "text-paragraph focus:outline-none focus:border-primary",
            "transition-colors",
          ],
          value: draft,
          onInput: (e: InputEvent) =>
            draft.set((e.target as HTMLInputElement).value),
        }),
        $.button(
          {
            type: "submit",
            class: [
              "flex items-center justify-center h-9 w-9 rounded-md",
              "bg-primary text-primary-content cursor-pointer",
              "hover:opacity-90 transition-opacity",
            ],
            "aria-label": "Add todo",
          },
          $.span({
            class: "[&_svg]:w-4 [&_svg]:h-4 block",
            innerHTML: Plus,
          }),
        ),
      ),
      when(isLoading, {
        onTrue: () =>
          $.div(
            {
              class: ["flex items-center justify-center py-4"],
            },
            $.div(
              {
                class: [
                  "w-4 h-4 border-2 border-t-transparent border-primary rounded-full",
                  "animate-spin",
                ],
              },
              "",
            ),
          ),
        onFalse: () =>
          each(orderedTodos, {
            key: (t) => t.id,
            container: () => $.ul({ class: "flex flex-col" }),
            render: (item) =>
              TodoItem({
                todo: item,
                onToggle: toggle,
                onRemove: remove,
              }),
            // enter, exit and move animations for the list items
            animate: {
              enterFrom: "!grid-rows-[0fr] !opacity-0",
              enter: "transition-all duration-300 ease-out",
              exit: "transition-all duration-300 ease-out",
              exitTo: "!grid-rows-[0fr] !opacity-0",
              move: {
                transform: Animation.moveTranslate3d,
                transition: "transition-all duration-300 ease-out",
              },
            },
          }),
      }),
    );
  });

Environment
Agnostic

Single Page Apps (SPA)
Client-side rendering with Stax is direct. Signals drive the DOM without a virtual tree, Effect handles asynchrony and lifecycle, and the router keeps navigation type-safe. Point Vite at your entry file and you're done — no framework-specific build ceremony.
Server-side Rendering
Server-side rendering piggybacks on @effect/platform. Rather than shipping our own HTTP server, Stax integrates with the primitives you'd already reach for building any Effect service. Your rendered app and your data layer share one runtime, one context system, one story for errors.
Static Site Generation
SSG is a first-class output. Route loaders run at build time, pages render to HTML, and hydration wires the same components back up in the browser. This site — the one you're reading — is built with Stax's SSG pipeline.
// Same component, three targets.

// SPA — client-side only
mount(App(), root);

// SSR — server renders, client hydrates
// server:
const routes = Platform.toHttpRoutes(router, opts);
// client:
hydrate(App(), root);

// SSG — pre-render at build time
Route.static({
  paths: () => discoverPages(),
  load: ({ params }) => loadPage(params.slug),
  render: (data) => DocPage(data),
});

Effect Native

Every Stax component is an Effect. Typed errors, dependency injection, structured concurrency, resource cleanup — you don't reach for a wrapper library, you just use Effect. If you know how to write a service in Effect, you already know how to write a component in Stax.

Components can yield* services the same way any other Effect can. The same Layer that provides your API client to a server route provides it to a UI component — no adapter, no duplicated wiring.

const handleUpdate = (delta: number) => () =>
  Effect.gen(function* () {
    const blocked = yield* isBlocked.get;

    if (!blocked) {
      yield* count.update((n) => n + delta);
      yield* isBlocked.set(true);

      // The update is blocked for 2 seconds, then unblocked.
      yield* Effect.sleep("2 seconds").pipe(
        Effect.andThen(() => isBlocked.set(false)),
        Effect.forkDaemon,
      );
    }
  });

Reactive State

Signals in Stax are references, not snapshots. Create one with Signal.make, read it with .get, update it with .set or .update. Pass a signal directly as an attribute or a child, and Stax subscribes on your behalf — the DOM updates automatically as the value changes.

Derived values come from Readable.map and friends. They stay in sync with their inputs without a dependency-tracking runtime and without you thinking about it. If your state model is a tree of signals and readables, your UI is already reactive.

import { $, Signal, Readable } from "@stax-ui/dom";

const name = yield* Signal.make("hello");

yield* name.update((n) => n + " world");

// The h1 updates its innerHTML when the signal changes.
yield* $.h1(name); // "hello world"

// Derived values react too, no extra plumbing.
const shout = Readable.map(name, (n) => n.toUpperCase());
yield* $.p( shout); // "HELLO WORLD"

Familiar Style

If you've written React, Solid, or Vue, Stax will feel familiar. Components are functions that return trees of elements, composition works the way you'd expect, and props are just arguments. There's no JSX transform, no virtual DOM, no reconciler — the tree you build is the tree that renders.

The $ factory replaces JSX with plain function calls that read as cleanly. Attributes go in the first argument, children follow. If a React refactor is muscle memory for you, moving to Stax rewires very little.

import { Effect } from "effect";
import { $, Signal } from "@stax-ui/dom";

// Components are just functions.
// Props are just arguments.
const Greeting = (props: { name: string }) =>
  Effect.gen(function* () {
    const count = yield* Signal.make(0);
    return yield* $.div(
      $.h1({}, `Hello, ${props.name}!`),
      $.button(
        { onClick: () => count.update((n) => n + 1) },
        count,
      ),
    );
  });

Confident
Development

Every element in Stax carries its error and dependency types in its signature. Element<HttpError, ApiClient> says: this component might fail with an HttpError, and it needs an ApiClient. TypeScript refuses to mount it until you handle the error and provide the service.

Deployment stops being a leap of faith. The compiler tells you when a route is missing context, when a Layer isn't wired up, when an error case slipped past. What ships is what typechecked.

// This component can fail — the error type says so.
const UserProfile = 
  (id: string): Element<HTMLDivElement, HttpError, ApiClient> =>
    Effect.gen(function* () {
      const api = yield* ApiClient;
      const user = yield* api.getUser(id);
      return yield* $.div({}, user.name);
    });

// TypeScript won't let you mount this without
// handling HttpError and providing ApiClient.
// Errors are visible in the types, not hidden at runtime.