@effex/dom
v1.1.0
Published
DOM rendering for Effex - a reactive UI framework built on Effect.ts
Maintainers
Readme
@effex/dom
DOM rendering for Effex applications. This package provides elements, components, control flow primitives, animation, SSR/hydration, and mounting utilities.
Note: This package re-exports everything from
@effex/core. You don't need to install both.
Installation
pnpm add @effex/dom effectSubpath Exports
| Import Path | Purpose |
|-------------|---------|
| @effex/dom | Main export — elements, control flow, utilities, and all of @effex/core |
| @effex/dom/server | Server-side rendering (renderToString) |
| @effex/dom/hydrate | Client-side hydration (hydrate) |
Basic Usage
Simple Components
Components without state or context requirements can be plain functions:
import { $, collect } from "@effex/dom";
const Greeting = (props: { name: string }) =>
$.div({ class: "greeting" }, collect(
$.h1({}, $.of(`Hello, ${props.name}!`)),
$.p({}, $.of("Welcome to Effex")),
));Stateful Components
Components that need signals, context, or other Effects use Effect.gen:
import { Effect } from "effect";
import { $, collect, Signal } 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("+")),
));
});Running Your App
Use runApp and mount to start your application:
import { Effect } from "effect";
import { mount, runApp } from "@effex/dom";
runApp(
Effect.gen(function* () {
yield* mount(App(), document.getElementById("root")!);
}),
);runApp handles boilerplate: scoping, SignalRegistry, and keeping the app alive. You can also pass a layer option for additional services:
runApp(
Effect.gen(function* () {
yield* mount(App(), document.getElementById("root")!);
}),
{ layer: Navigation.makeLayer(router) },
);Elements
The $ namespace contains factories for all HTML and SVG elements. Elements are Effects that produce DOM nodes:
yield* $.div({ class: "container", style: { color: "red" } }, collect(
$.h1({}, $.of(name)),
$.p({}, $.of(t`${count} items`)),
));Children
Use $.of() to lift primitives and Readables into children, and collect() to combine multiple children:
// Single child
yield* $.h1({}, $.of("Hello World"));
// Multiple children
yield* $.div({}, collect(
$.of("Hello"),
$.span({}, $.of("World")),
));
// Empty child
yield* $.div({}, $.empty);Attributes
Elements accept an optional attributes object as the first argument:
$.input({
// Standard attributes
type: "text",
placeholder: "Enter name",
disabled: true,
id: "name-input",
// Reactive attributes — UI updates automatically
value: name, // Readable<string>
class: className, // Readable<string>
hidden: isHidden, // Readable<boolean>
// Style as object or string
style: { color: "red", fontSize: "16px" },
// Class as string, array, or Readable
class: ["btn", isActive.pipe(Readable.map(a => a ? "btn-active" : ""))],
// Data and ARIA attributes
"data-testid": "name",
"aria-label": "Name input",
role: "textbox",
// Ref binding
ref: inputRef,
});Event Handlers
Event handlers are functions that optionally return an Effect:
$.button({
onClick: (e) => count.update((n) => n + 1),
onKeyDown: (e) => {
if (e.key === "Enter") return submit.run();
},
onSubmit: (e) => {
e.preventDefault();
return handleSubmit();
},
});Supported events include: onClick, onInput, onChange, onSubmit, onKeyDown, onKeyUp, onFocus, onBlur, onMouseDown, onMouseUp, onMouseEnter, onMouseLeave, onPointerDown, onPointerUp, onPointerMove, onScroll, onWheel, onDragStart, onDrag, onDragEnd, onDrop, onDragOver, onTouchStart, onTouchMove, onTouchEnd, onAnimationEnd, onTransitionEnd, and more.
SVG Elements
SVG elements are also available on $:
$.svg({ viewBox: "0 0 24 24", width: 24, height: 24 },
$.path({ d: "M12 2L2 22h20L12 2z", fill: "currentColor" }),
);Template Strings
The t tagged template creates reactive strings from Readables:
import { t } from "@effex/dom";
const name = yield* Signal.make("World");
const count = yield* Signal.make(0);
// Creates a Readable<string> that updates automatically
yield* $.p({}, $.of(t`Hello, ${name}! Count: ${count}`));Defining Components
Simple (No State/Context)
Components without state or context requirements are plain functions that return an Element:
const Greeting = (props: { name: string }) =>
$.h1({}, $.of(`Hello, ${props.name}!`));
// With children — generic over E and R to propagate types
const Card = <E, R>(props: { title: string }, children: Child<E, R>) =>
$.div({ class: "card" }, collect(
$.h2({}, $.of(props.title)),
children,
));With State or Context
Use Effect.gen when you need signals, context, or other Effects:
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("+")),
));
});
const UserBadge = () =>
Effect.gen(function* () {
const user = yield* UserContext; // Requires context
return yield* $.span({}, $.of(user.name));
});Context Providers
import { Context, Effect } from "effect";
import { $, provide } from "@effex/dom";
class ThemeContext extends Context.Tag("ThemeContext")<ThemeContext, Theme>() {}
const ThemedButton = (props: { label: string }) =>
Effect.gen(function* () {
const theme = yield* ThemeContext;
return yield* $.button(
{ style: { backgroundColor: theme.primary } },
$.of(props.label),
);
});
// Provide context to children
$.div({}, provide(ThemeContext, theme, ThemedButton({ label: "Click" })));Control Flow
when
Conditionally render based on a reactive boolean:
import { when } from "@effex/dom";
when(isLoggedIn, {
onTrue: () => $.div({}, $.of("Welcome back!")),
onFalse: () => $.div({}, $.of("Please log in")),
});match
Pattern match on a reactive value:
import { match } from "@effex/dom";
match(status, {
cases: [
{ pattern: "loading", render: () => Spinner() },
{ pattern: "error", render: () => ErrorMessage() },
{ pattern: "success", render: () => Content() },
],
fallback: () => $.div({}, $.of("Unknown")),
});each
Render a list with automatic keying and reconciliation:
import { each } from "@effex/dom";
each(todos, {
key: (todo) => todo.id,
render: (todo, index) => TodoItem({ todo, index }),
container: () => $.ul({ class: "todo-list" }),
});The render callback receives Readable<T> items and Readable<number> indices — item identity is preserved across reorders. Only the changed items are updated.
matchOption / matchEither
Match on Option or Either values. The inner value is unwrapped as a Readable:
import { matchOption, matchEither } from "@effex/dom";
// userData.value is Readable<Option<User>>
matchOption(userData.value, {
onSome: (user) => UserCard({ user }), // user is Readable<User>
onNone: () => $.div({}, $.of("No user")),
});
matchEither(result, {
onRight: (value) => SuccessView({ value }), // value is Readable<A>
onLeft: (error) => ErrorView({ error }), // error is Readable<E>
});redraw
Completely rebuild the component subtree whenever a Readable changes (use sparingly — when/match/each are usually better):
import { redraw } from "@effex/dom";
redraw(locale, {
render: (currentLocale) => LocalizedApp({ locale: currentLocale }),
});Async Boundaries
Suspense
Handle async rendering with loading states:
import { Boundary } from "@effex/dom";
Boundary.suspense({
render: () =>
Effect.gen(function* () {
const user = yield* fetchUser(id);
return yield* UserProfile({ user });
}),
fallback: () => $.div({}, $.of("Loading...")),
catch: (error) => $.div({}, $.of(`Error: ${error.message}`)),
delay: "200 millis", // Avoid loading flash for fast responses
});Error Boundary
Boundary.error(
() => RiskyComponent(),
(error) => $.div({}, $.of(`Failed: ${error.message}`)),
);Server-Side Rendering
renderToString
import { renderToString } from "@effex/dom/server";
const handler = Effect.gen(function* () {
const html = yield* renderToString(App());
return new Response(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script src="/app.js"></script>
</body>
</html>
`);
});Options:
hydrate(default:true) — include hydration markers in the output. Set tofalsefor static rendering.
Hydration
import { hydrate } from "@effex/dom/hydrate";
import { App } from "./App";
hydrate(App(), document.getElementById("root")!);With options:
hydrate(App(), document.getElementById("root")!, {
onMismatch: (message, node) => console.warn("Mismatch:", message),
layers: myAppLayer, // Additional layers to provide during hydration
});Hydration attaches to server-rendered HTML and sets up reactive bindings without re-rendering the DOM. The component tree must match what was rendered on the server.
Element Manipulation
The Element namespace provides pipeable functions for imperative DOM manipulation. These are useful for refs and one-off operations:
import { Element, ref } from "@effex/dom";
const buttonRef = yield* ref<HTMLButtonElement>();
// Pipe operations on the ref
yield* buttonRef.pipe(
Element.addClass("active"),
Element.setStyles({ color: "blue" }),
Element.focus,
);Attribute Operations
yield* el.pipe(Element.setAttribute("aria-expanded", "true"));
yield* el.pipe(Element.removeAttribute("disabled"));
yield* el.pipe(Element.toggleAttribute("hidden"));
yield* el.pipe(Element.hasAttribute("disabled")); // Effect<boolean>
// Reactive binding — attribute updates when readable changes
yield* el.pipe(Element.bindAttribute("aria-label", labelReadable));
yield* el.pipe(Element.bindBooleanAttribute("disabled", isDisabled));Class Operations
yield* el.pipe(Element.addClass("active", "highlighted"));
yield* el.pipe(Element.removeClass("loading"));
yield* el.pipe(Element.toggleClass("expanded"));
yield* el.pipe(Element.replaceClass("old-class", "new-class"));
yield* el.pipe(Element.setClass("entirely-new-class"));
// Reactive binding
yield* el.pipe(Element.bindClass(classNameReadable));Style Operations
yield* el.pipe(Element.setStyle("backgroundColor", "red"));
yield* el.pipe(Element.setStyles({ opacity: "1", fontSize: "16px" }));
yield* el.pipe(Element.removeStyle("color"));
// Reactive binding
yield* el.pipe(Element.bindStyle("color", colorReadable));Data Attributes
yield* el.pipe(Element.setData("state", "open")); // data-state="open"
yield* el.pipe(Element.removeData("state"));
yield* el.pipe(Element.getData("state")); // Effect<string, DataAttributeNotFound>
// Reactive binding
yield* el.pipe(Element.bindData("state", stateReadable));Content Operations
yield* el.pipe(Element.setTextContent("Hello"));
yield* el.pipe(Element.setInnerHTML("<em>bold</em>"));
yield* el.pipe(Element.setInputValue("new value")); // Without cursor reset
// Reactive bindings
yield* el.pipe(Element.bindTextContent(textReadable));
yield* el.pipe(Element.bindInputValue(valueReadable));Focus & Interaction
yield* el.pipe(Element.focus);
yield* el.pipe(Element.blur);
yield* el.pipe(Element.click);
yield* el.pipe(Element.focusFirst("[data-item]")); // Focus first matching descendant
yield* el.pipe(Element.focusLast("[data-item]"));
yield* el.pipe(Element.getBoundingClientRect); // Effect<DOMRect>
yield* el.pipe(Element.getId); // Effect<string>
yield* el.pipe(Element.contains(childNode)); // Effect<boolean>Event Listeners
yield* el.pipe(Element.on("click", (e) => handleClick(e)));
yield* el.pipe(Element.once("transitionend", (e) => afterTransition()));Utility
yield* el.pipe(Element.tap((node) => console.log(node)));
yield* el.pipe(Element.tapEffect((node) => Effect.log("mounted")));Animation
CSS-based animations for control flow transitions:
import { when, each, stagger } from "@effex/dom";
// Enter/exit animations
when(isOpen, {
onTrue: () => Modal(),
onFalse: () => $.span(),
animate: {
enterFrom: "opacity-0 scale-95",
enterTo: "opacity-100 scale-100",
exit: "fade-out",
},
});
// Staggered list animations
each(items, {
key: (item) => item.id,
render: (item) => ListItem(item),
animate: {
enter: "slide-in",
exit: "slide-out",
stagger: stagger(50), // 50ms between items
},
});Stagger Functions
| Function | Description |
|----------|-------------|
| stagger(delayMs) | Fixed delay between items |
| staggerFromCenter(delayMs) | Items animate outward from center |
| staggerEased(totalDurationMs, easingFn) | Custom easing curve |
| delay(delayMs) | Fixed delay for all items |
| sequence(...delays) | Sequential delays |
| parallel() | All items animate simultaneously |
Virtual List
For large lists (1000+ items), use virtualEach to only render visible items:
import { virtualEach, VirtualListRef } from "@effex/dom";
// Basic usage
virtualEach(items, {
key: (item) => item.id,
itemHeight: 48,
height: 400,
render: (item) => $.li({}, $.of(item.pipe(Readable.map((i) => i.text)))),
});With scroll control:
const listRef = yield* VirtualListRef.make();
yield* virtualEach(items, {
key: (item) => item.id,
itemHeight: 60,
height: 400,
overscan: 5, // Render 5 extra items above/below viewport
ref: listRef,
render: (item, index) => ListItem({ item, index }),
});
// Scroll to item 100
yield* listRef.ready.pipe(
Effect.flatMap((control) => control.scrollTo(100))
);
// Other controls
control.scrollToTop();
control.scrollToBottom();
control.visibleRange; // Readable<{ start: number, end: number }>
control.totalItems; // Readable<number>Portal
Render children into a different DOM node:
import { Portal } from "@effex/dom";
// Render into document.body (default)
Portal(() => Modal({ title: "Hello" }));
// Render into specific element
Portal({ target: "#modal-root" }, () => Dropdown());
Portal({ target: existingElement }, () => Tooltip());DOM Utilities
Ref
Create refs to DOM elements for imperative access:
import { ref } from "@effex/dom";
const inputRef = yield* ref<HTMLInputElement>();
// Pass to element
yield* $.input({ ref: inputRef, type: "text" });
// Use later — waits until element is mounted
yield* inputRef.pipe(Element.focus);
// Check connection status
inputRef.isConnected; // Readable<boolean>FocusTrap
Trap focus within a container (for modals, dialogs):
import { FocusTrap } from "@effex/dom";
yield* FocusTrap.make({
container: dialogElement,
initialFocus: firstInput, // Optional: focus this element first
returnFocus: triggerElement, // Optional: focus returns here on release
});
// Focus is trapped until scope closesScrollLock
Prevent body scrolling (for modals). Accounts for scrollbar width to prevent layout shift:
import { ScrollLock } from "@effex/dom";
yield* ScrollLock.lock;
// Body scroll is locked until scope closesUniqueId
Generate unique IDs for ARIA relationships:
import { UniqueId } from "@effex/dom";
const labelId = yield* UniqueId.make("label");
const inputId = yield* UniqueId.make("input");
yield* $.div({}, collect(
$.label({ id: labelId, htmlFor: inputId }, $.of("Name")),
$.input({ id: inputId, "aria-labelledby": labelId }),
));API Reference
Elements
| Export | Description |
|--------|-------------|
| $.<element>(attrs?, children?) | Create an HTML/SVG element |
| $.of(value) | Lift a primitive or Readable into a child |
| $.empty | Empty child (produces no DOM nodes) |
| collect(...children) | Combine multiple children |
| t\template`| Create reactive template string |
|provide(tag, value, children)` | Provide context to children |
Mounting
| Export | Description |
|--------|-------------|
| mount(element, container) | Mount an element into a DOM container |
| runApp(program, options?) | Run an application with scoping and lifecycle |
| renderToString(element, options?) | SSR — from @effex/dom/server |
| hydrate(element, container, options?) | Hydration — from @effex/dom/hydrate |
Control Flow
| Export | Description |
|--------|-------------|
| when(condition, config) | Conditional rendering |
| match(value, config) | Pattern matching |
| each(items, config) | Keyed list rendering |
| matchOption(option, config) | Match on Option |
| matchEither(either, config) | Match on Either |
| redraw(readable, config) | Full redraw on change |
Boundaries
| Export | Description |
|--------|-------------|
| Boundary.suspense(options) | Async loading boundary with fallback |
| Boundary.error(render, catch) | Error boundary |
Animation
| Export | Description |
|--------|-------------|
| stagger(delayMs) | Linear stagger between items |
| staggerFromCenter(delayMs) | Center-out stagger |
| staggerEased(totalMs, easingFn) | Easing-based stagger |
| delay(delayMs) | Fixed delay |
| sequence(...delays) | Sequential delays |
| parallel() | Simultaneous animation |
Virtual List
| Export | Description |
|--------|-------------|
| virtualEach(items, config) | Virtualized list rendering |
| VirtualListRef.make() | Create scroll control ref |
DOM Utilities
| Export | Description |
|--------|-------------|
| ref<T>() | Create element ref |
| FocusTrap.make(options) | Trap focus in container |
| ScrollLock.lock | Lock body scroll |
| UniqueId.make(prefix?) | Generate unique ID |
| Portal(options?, children) | Render to different DOM node |
Element Namespace
The Element namespace contains all pipeable manipulation functions. Key categories:
| Category | Functions |
|----------|-----------|
| Attributes | setAttribute, removeAttribute, toggleAttribute, bindAttribute, bindBooleanAttribute |
| Classes | setClass, addClass, removeClass, toggleClass, replaceClass, bindClass |
| Styles | setStyle, setStyles, removeStyle, bindStyle |
| Data | setData, removeData, getData, bindData |
| Content | setTextContent, setInnerHTML, setInputValue, bindTextContent, bindInnerHTML, bindInputValue |
| Focus | focus, blur, click, focusFirst, focusLast |
| Events | on, once, addEventListener, removeEventListener |
| Query | getBoundingClientRect, getId, hasAttribute, contains |
| Children | appendChild, clearChildren |
| Misc | setRef, tap, tapEffect |
Renderer
| Export | Description |
|--------|-------------|
| DOMRenderer | DOM implementation of the Renderer interface |
| DOMRendererLive | Layer providing DOMRenderer |
