Effect-native JSX renderer with automatic reactivity, type-safe routing, SSR, and live server-sent data.
npm install fibrae @effect-atom/atom effectConfigure TypeScript for JSX:
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "fibrae"
}
}Components are functions that return one of three types:
| Return type | Use case |
|---|---|
VElement |
Static markup, no async work or services needed |
Effect<VElement> |
Async data, service access, atom reads |
Stream<VElement> |
Live-updating UI that re-renders on each emission |
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import * as Schedule from "effect/Schedule";
import { Atom, AtomRegistry } from "fibrae";
// Static component -- returns VElement directly
const Header = () => <h1>Hello</h1>;
// Effect component -- can yield services and read atoms
const Counter = () =>
Effect.gen(function* () {
const registry = yield* AtomRegistry.AtomRegistry;
const count = yield* Atom.get(countAtom);
return <button onClick={() => registry.update(countAtom, (n) => n + 1)}>Count: {count}</button>;
});
// Stream component -- emits new VElements over time
const Clock = () =>
Stream.fromSchedule(Schedule.spaced("1 second")).pipe(
Stream.scan(0, (n) => n + 1),
Stream.map((seconds) => <span>Uptime: {seconds}s</span>),
);Effect and Stream components automatically re-render when accessed atoms change.
State is managed through atoms from @effect-atom/atom.
import { Atom, AtomRegistry } from "fibrae";
const countAtom = Atom.make(0);| API | Description |
|---|---|
Atom.make(initial) |
Create an atom with an initial value |
Atom.get(atom) |
Read value inside an Effect (subscribes component to changes) |
registry.get(atom) |
Read value synchronously (e.g. in event handlers) |
registry.set(atom, value) |
Set a new value |
registry.update(atom, fn) |
Update with a function (current) => next |
registry.modify(atom, fn) |
Update and return a derived value (current) => [result, next] |
Atom.family(fn) |
Create parameterized atoms -- fn(key) returns a unique atom per key |
Atom.serializable(atom, { key, schema }) |
Mark atom for SSR state transfer |
Inside Effect components, use Atom.get to read atoms. This subscribes the component to changes -- when the atom's value changes, the component automatically re-renders.
For writes, obtain the AtomRegistry service and call set or update:
const TodoList = () =>
Effect.gen(function* () {
const registry = yield* AtomRegistry.AtomRegistry;
const todos = yield* Atom.get(todosAtom);
return (
<ul>
{todos.map((t) => (
<li>{t}</li>
))}
<button onClick={() => registry.update(todosAtom, (ts) => [...ts, "New"])}>Add</button>
</ul>
);
});Atoms marked with Atom.serializable are included in SSR dehydration and automatically restored during client hydration:
import * as Schema from "effect/Schema";
const themeAtom = Atom.make<"light" | "dark">("light").pipe(
Atom.serializable({ key: "app-theme", schema: Schema.Literal("light", "dark") }),
);Effect components can access their lifecycle via the ComponentScope service. It provides:
scope-- an EffectScopefor registering cleanup logic that runs on unmountmounted-- aDeferred<void>that resolves after the component's DOM subtree is committed
import * as Effect from "effect/Effect";
import * as Scope from "effect/Scope";
import * as Deferred from "effect/Deferred";
import { ComponentScope } from "fibrae";
import { pipe } from "effect/Function";
const JsonEditor = () =>
Effect.gen(function* () {
const { scope, mounted } = yield* ComponentScope;
const containerRef = { current: null as HTMLDivElement | null };
// Fork a fiber that waits for mount, then initializes a third-party library
yield* pipe(
Effect.gen(function* () {
yield* Deferred.await(mounted);
const editor = monaco.create(containerRef.current!);
yield* Scope.addFinalizer(
scope,
Effect.sync(() => editor.dispose()),
);
}),
Effect.forkScoped,
Scope.extend(scope),
);
return <div ref={(el) => (containerRef.current = el)} />;
});For simple cleanup without waiting for mount:
const Tracker = () =>
Effect.gen(function* () {
const { scope } = yield* ComponentScope;
yield* Scope.addFinalizer(
scope,
Effect.sync(() => analytics.cleanup()),
);
return <div>Tracking active</div>;
});Event handler props (onClick, onSubmit, etc.) can return Effect values. When they do, the Effect is automatically forked with the full application context -- including all services provided to render().
// Plain event handler
const Button1 = () => <button onClick={() => console.log("clicked")}>Plain</button>;
// Effect event handler -- forked automatically
const Button2 = () => <button onClick={() => Effect.log("clicked via Effect")}>Effectful</button>;
// Access services in event handlers
const LogoutButton = () =>
Effect.gen(function* () {
const auth = yield* AuthService;
return <button onClick={() => auth.logout()}>Log out</button>;
});If an Effect event handler fails, the error is wrapped in EventHandlerError and caught by the nearest ErrorBoundary.
The router is available via fibrae/router. It follows the Effect HttpApi pattern: declare routes, organize into groups, then implement handlers separately.
import { Route, Router, RouterBuilder, Link, RouterOutlet } from "fibrae/router";
import { Navigator, NavigatorLive, BrowserHistoryLive } from "fibrae/router";import * as Schema from "effect/Schema";
// Static path
const homeRoute = Route.get("home", "/");
// Dynamic path with schema-validated parameters (template literal syntax)
const postRoute = Route.get("post")`/posts/${Route.param("id", Schema.NumberFromString)}`;
// Query parameters
const searchRoute = Route.get("search", "/search").setSearchParams(
Schema.Struct({ q: Schema.String, page: Schema.optional(Schema.NumberFromString) }),
);Routes are organized into groups, then groups are added to a router:
// Simple group
const appRouter = Router.make("app").add(Router.group("main").add(homeRoute).add(postRoute));
// Layout group -- wraps child routes with a layout component
// Child routes are matched relative to the basePath
const appRouter = Router.make("app")
.add(Router.group("public").add(homeRoute))
.add(
Router.layout("dashboard", "/dashboard")
.add(Route.get("overview", "/overview")) // matches /dashboard/overview
.add(Route.get("settings", "/settings")), // matches /dashboard/settings
);Use RouterBuilder.group for regular groups and RouterBuilder.layoutGroup for layout groups:
const MainRoutesLive = RouterBuilder.group(appRouter, "main", (handlers) =>
handlers
.handle("home", {
component: () => <h1>Home</h1>,
head: () => ({ title: "Home" }),
})
.handle("post", {
loader: ({ path }) => fetchPost(path.id), // plain value or Effect
component: ({ loaderData }) => <PostPage post={loaderData} />,
head: ({ loaderData }) => ({ title: loaderData.title }),
}),
);
const DashboardRoutesLive = RouterBuilder.layoutGroup(appRouter, "dashboard", (handlers) =>
handlers
.layout(() => (
<div class="dashboard">
<Sidebar />
<RouterOutlet />
</div>
))
.handle("overview", { component: () => <Overview /> })
.handle("settings", { component: () => <Settings /> }),
);Handler config options:
| Field | Type | Description |
|---|---|---|
component |
(props) => VElement |
Required. Receives { loaderData, path, searchParams } |
loader |
(ctx) => T | Effect<T> |
Optional. Runs before component, result passed as loaderData |
head |
(ctx) => HeadData | Effect<HeadData> |
Optional. Per-route <head> metadata |
prerender |
boolean |
Optional. Mark route for static pre-rendering |
getStaticPaths |
() => PathParams[] | Effect<PathParams[]> |
Optional. Enumerate params for prerender |
action |
ActionConfig |
Optional. Form mutation handler (schema + handler Effect) |
Link takes a real path via the href prop — just import and use:
import { Link } from "fibrae/router";
<Link href="/">Home</Link>
<Link href={`/posts/${id}`}>Post {id}</Link>
<Link href="/search" search={{ q: "effect" }}>Search</Link>
<Link href="/posts" replace>Posts (replace)</Link>For type-safe hrefs, register your router via module augmentation:
declare module "fibrae/router" {
interface RegisteredRouter {
AppRouter: typeof AppRouter;
}
}
// Now <Link href="/typo" /> is a compile-time error!
// But <Link href={`/posts/${id}`} /> passes — matches /posts/${string}Link renders an <a> with the correct href (works with SSR) and intercepts clicks for SPA navigation. It applies an "active" CSS class when the current pathname matches (customizable via activeClass prop).
RouterOutlet subscribes to the current route and renders the matched handler's component. For layout groups, nested RouterOutlet components render at increasing depth:
const App = () => (
<div>
<Nav />
<RouterOutlet />
</div>
);OutletDepth is a context tag that tracks nesting level, managed automatically by the renderer.
Form provides declarative form submission with schema-decoded payloads. It connects to the current route's action by default, or accepts an explicit action for fetcher-style usage.
import * as Schema from "effect/Schema";
import { Form } from "fibrae/router";
// Route with an action
handlers.handle("createPost", {
action: {
schema: Schema.Struct({ title: Schema.String, body: Schema.String }),
handler: ({ payload }) => createPost(payload),
},
component: () => (
<Form>
<input name="title" />
<textarea name="body" />
<button type="submit">Create</button>
</Form>
),
});Submission lifecycle:
- Serialize
FormDatainto a plain record - Decode via the action's schema -- validation errors skip the action
- Invoke the action Effect with the decoded payload
- State transitions:
Idle→Pending→Success/Failure - Navigate after success (unless
navigate={false})
| Prop | Type | Description |
|---|---|---|
action |
RouteAction |
Explicit action (overrides route action) |
schema |
Schema.Any |
Schema to decode FormData (required with explicit action) |
navigate |
boolean |
Skip navigation after success when false |
navigateTo |
string |
Path to navigate to after success |
onSuccess |
(data) => void |
Callback on successful submission |
onError |
(error) => void |
Callback on failed submission |
The FormState service is available inside Form children to read SubmissionState (Idle, Pending, Success, Failure).
The Navigator service provides path-based navigation:
const GoHomeButton = () =>
Effect.gen(function* () {
const navigator = yield* Navigator;
return <button onClick={() => navigator.go("/")}>Go Home</button>;
});
// Navigate to a path
navigator.go("/posts/42");
// With search params
navigator.go("/search", { search: { q: "effect" } });
// Replace instead of push
navigator.go("/settings", { replace: true });
// View Transitions API
navigator.go("/posts", { viewTransition: true });
// Back / forward
navigator.back;
navigator.forward;
// Check active state
const active = yield* navigator.isActive("/posts");import * as Layer from "effect/Layer";
import { pipe } from "effect/Function";
import { render } from "fibrae";
import { NavigatorLive, BrowserHistoryLive } from "fibrae/router";
const routerLayer = pipe(
NavigatorLive(appRouter),
Layer.provideMerge(BrowserHistoryLive),
Layer.provideMerge(MainRoutesLive),
);
render(<App />, document.getElementById("root")!, { layer: routerLayer });| Layer | Description |
|---|---|
BrowserHistoryLive |
Real browser history with popstate handling |
MemoryHistoryLive(options?) |
In-memory stack for SSR and testing |
MemoryHistoryLive accepts initialPathname, initialSearch, initialHash, and initialState.
ErrorBoundary catches errors in its subtree and shows a fallback. It supports recovery — when children re-emit (e.g. route change), the boundary resets and shows the new content.
import { ErrorBoundary } from "fibrae";
const App = () => (
<ErrorBoundary fallback={(error) => <div>Error: {error._tag}</div>}>
<RouterOutlet />
</ErrorBoundary>
);The fallback receives a ComponentError union. Match on _tag for per-type handling:
const fallback = (error: ComponentError) => {
switch (error._tag) {
case "RenderError":
return <div>Render failed: {error.componentName}</div>;
case "StreamError":
return <div>Stream failed: {error.phase}</div>;
case "EventHandlerError":
return <div>Event {error.eventType} failed</div>;
}
};Error types:
| Type | Fields | When |
|---|---|---|
RenderError |
cause, componentName? |
Component threw during render or its Effect failed |
StreamError |
cause, phase |
Stream component failed ("before-first-emission" or "after-first-emission") |
EventHandlerError |
cause, eventType |
An Effect event handler failed (e.g. eventType: "click") |
Boundaries nest naturally — inner boundaries catch first, unhandled errors propagate outward.
Suspense uses a threshold-based strategy: it tries to render children immediately. If children take longer than threshold ms (default 100), the fallback is shown until children complete.
import { Suspense } from "fibrae";
const App = () => (
<Suspense fallback={<div>Loading...</div>} threshold={200}>
<SlowComponent />
</Suspense>
);Works with Effect components (async service calls) and Stream components. During SSR, Suspense emits HTML comment markers (<!--fibrae:sus:resolved--> or <!--fibrae:sus:fallback-->) so the client can hydrate correctly.
Server-side rendering produces HTML plus serialized atom state.
renderToString creates its own AtomRegistry internally. Use it for simple cases:
import * as Effect from "effect/Effect";
import { renderToString } from "fibrae/server";
const program = Effect.gen(function* () {
const { html, dehydratedState } = yield* renderToString(<App />);
return `<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script type="application/json" id="__fibrae-state__">${JSON.stringify(dehydratedState)}</script>
<script src="/client.js"></script>
</body>
</html>`;
});
const page = await Effect.runPromise(program);When your components require additional services (e.g. routing), use renderToStringWith and provide layers yourself:
import { renderToStringWith, SSRAtomRegistryLayer } from "fibrae/server";
import * as Layer from "effect/Layer";
const program = Effect.gen(function* () {
const { html, dehydratedState } = yield* renderToStringWith(<App />);
return { html, dehydratedState };
});
await Effect.runPromise(
program.pipe(
Effect.provide(Layer.mergeAll(SSRAtomRegistryLayer, navigatorLayer, routerHandlersLayer)),
),
);The client auto-discovers dehydrated state from the <script id="__fibrae-state__"> tag. No manual state passing is needed:
import { render } from "fibrae";
render(<App />, document.getElementById("root")!, { layer: routerLayer });If the container has existing child elements (from SSR), fibrae uses hydration mode: it walks the existing DOM and attaches event handlers without replacing nodes.
Use Router.serverLayer() on the server and Router.browserLayer() on the client:
// Server
const serverLayer = Router.serverLayer({
router: appRouter,
pathname: "/posts/42",
search: "?sort=date",
basePath: "/app",
});
// Provides CurrentRouteElement, History, Navigator
// Requires RouterHandlers + AtomRegistry
// Client
const browserLayer = Router.browserLayer({
router: appRouter,
basePath: "/app",
});
render(<App />, root, { layer: browserLayer });Only atoms marked with Atom.serializable are included in dehydrated state. The schema handles encoding/decoding:
const userAtom = Atom.make<User | null>(null).pipe(
Atom.serializable({ key: "current-user", schema: Schema.NullOr(UserSchema) }),
);The live system (fibrae/live) provides real-time server-to-client data sync over Server-Sent Events (SSE).
live(event, { schema }) creates an atom backed by an SSE source. The atom's type is Result<A>:
Result.initial()before SSE connectsResult.success(value)on each event
Live atoms are automatically serializable for SSR hydration.
import * as Schema from "effect/Schema";
import { live } from "fibrae/live";
import { Result } from "fibrae";
const ClockAtom = live("clock", { schema: Schema.String });
// In a component
const LiveClock = () =>
Effect.gen(function* () {
const clock = yield* Atom.get(ClockAtom);
return Result.match(clock, {
onInitial: () => <span>Connecting...</span>,
onSuccess: (time) => <span>Server time: {time}</span>,
});
});serve() creates an SSE endpoint for a single live atom. serveGroup() multiplexes multiple atoms over one connection.
import { serve, serveGroup } from "fibrae/live";
import { HttpRouter } from "@effect/platform";
// Single channel
const clockHandler = serve(ClockAtom, {
source: Effect.sync(() => new Date().toISOString()),
interval: "1 second",
});
// Multiple channels over one connection
const groupHandler = serveGroup({
channels: [
{
channel: ClockAtom,
source: Effect.sync(() => new Date().toISOString()),
interval: "1 second",
},
{ channel: StatsAtom, source: fetchStats, interval: "5 seconds" },
],
heartbeatInterval: "30 seconds",
});
// Wire into your HTTP router
HttpRouter.get("/api/live", clockHandler);serve() options:
| Option | Default | Description |
|---|---|---|
source |
required | Effect that fetches current state |
interval |
"2 seconds" |
Polling interval |
equals |
Equal.equals |
Deduplication function, or false to disable |
heartbeatInterval |
"30 seconds" |
SSE keepalive interval, or false to disable |
retryInterval |
-- | SSE retry hint sent to client |
Provide LiveConfig in your render layer to tell the client where to connect. Live atoms auto-connect when detected during render:
import { LiveConfig } from "fibrae/live";
import * as Layer from "effect/Layer";
const liveLayer = Layer.succeed(
LiveConfig,
LiveConfig.make({
baseUrl: "/api/live",
channels: {
clock: "/api/live/clock", // override per event name
},
}),
);
render(<App />, root, { layer: Layer.merge(routerLayer, liveLayer) });Use Effect services for dependency injection across the component tree. Define a service, provide it via a Layer to render(), and yield it in any component or event handler.
import * as Effect from "effect/Effect";
import { Atom, AtomRegistry } from "fibrae";
const themeAtom = Atom.make<"light" | "dark">("dark");
class ThemeService extends Effect.Service<ThemeService>()("ThemeService", {
accessors: true,
effect: Effect.gen(function* () {
const registry = yield* AtomRegistry.AtomRegistry;
return {
getTheme: () => Atom.get(themeAtom),
toggleTheme: () =>
Effect.sync(() => registry.update(themeAtom, (t) => (t === "light" ? "dark" : "light"))),
};
}),
}) {}
// Components yield services -- Suspense shows fallback during async resolution
const ThemedPanel = () =>
Effect.gen(function* () {
const theme = yield* ThemeService.getTheme();
return <div class={theme === "dark" ? "dark-panel" : "light-panel"}>Content</div>;
});
// Provide via Layer
render(<App />, root, { layer: ThemeService.Default });Key points:
- Services are Effect programs -- they can yield other services and access atoms
accessors: truegenerates static methods (ThemeService.getTheme()) for convenience- Atom changes in services trigger re-renders in all subscribing components
- Services are available in components, event handlers, and loaders
Each route handler can define a head() function that returns metadata for the document <head>:
handlers.handle("post", {
loader: ({ path }) => fetchPost(path.id),
component: ({ loaderData }) => <PostPage post={loaderData} />,
head: ({ loaderData }) => ({
title: loaderData.title,
meta: [
{ name: "description", content: loaderData.summary },
{ property: "og:title", content: loaderData.title },
],
links: [{ rel: "canonical", href: `https://example.com/posts/${loaderData.id}` }],
}),
});HeadData fields:
| Field | Type | Description |
|---|---|---|
title |
string |
Document title |
meta |
MetaDescriptor[] |
Meta tags (name/content, property/content, charset, httpEquiv, script:ld+json) |
links |
Record<string, string>[] |
Link tags |
scripts |
{ src?, content?, type? }[] |
Script tags |
Head data is rendered during SSR and updated on client-side navigation.
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { pipe } from "effect/Function";
import { render, Atom, AtomRegistry, Suspense, ErrorBoundary } from "fibrae";
import {
Route,
Router,
RouterBuilder,
Link,
RouterOutlet,
NavigatorLive,
BrowserHistoryLive,
Navigator,
} from "fibrae/router";
// --- Atoms ---
const countAtom = Atom.make(0);
// --- Routes ---
const homeRoute = Route.get("home", "/");
const postRoute = Route.get("post", "/posts/:id", { id: Schema.NumberFromString });
const appRouter = Router.make("app").add(Router.group("main").add(homeRoute).add(postRoute));
// Register router for type-safe Link href
declare module "fibrae/router" {
interface RegisteredRouter {
appRouter: typeof appRouter;
}
}
// --- Components ---
const Nav = () => (
<nav>
<Link href="/">Home</Link>
<Link href="/posts/1">Post 1</Link>
</nav>
);
const Counter = () =>
Effect.gen(function* () {
const registry = yield* AtomRegistry.AtomRegistry;
const count = yield* Atom.get(countAtom);
return <button onClick={() => registry.update(countAtom, (n) => n + 1)}>Count: {count}</button>;
});
const Clock = () =>
Stream.fromSchedule(Schedule.spaced("1 second")).pipe(
Stream.scan(0, (n) => n + 1),
Stream.map((seconds) => <span>Uptime: {seconds}s</span>),
);
// --- Route Handlers ---
const AppRoutesLive = RouterBuilder.group(appRouter, "main", (handlers) =>
handlers
.handle("home", {
component: () => (
<div>
<h1>Home</h1>
<Counter />
<Clock />
</div>
),
})
.handle("post", {
loader: ({ path }) => fetchPost(path.id),
component: ({ loaderData }) => <PostPage post={loaderData} />,
head: ({ loaderData }) => ({ title: loaderData.title }),
}),
);
// --- Error Boundary + Suspense ---
const App = () => (
<>
<Nav />
<ErrorBoundary fallback={(e) => <div>Error: {e._tag}</div>}>
<Suspense fallback={<div>Loading...</div>} threshold={100}>
<RouterOutlet />
</Suspense>
</ErrorBoundary>
</>
);
// --- Render ---
const routerLayer = pipe(
NavigatorLive(appRouter),
Layer.provideMerge(BrowserHistoryLive),
Layer.provideMerge(AppRoutesLive),
);
render(<App />, document.getElementById("root")!, { layer: routerLayer });The fibrae/mdx module renders markdown content as fibrae VElements. All services are optional -- it works out of the box with no configuration.
import { MDX, MdxProcessor, MdxHighlighter, MDXComponents } from "fibrae/mdx";
// Simplest usage — no services needed
const Docs = () => <MDX content={markdownString} />;
// With component overrides via props
const Styled = () => (
<MDX
content={markdownString}
components={{
h1: ({ children, ...props }) => <h1 class="text-4xl" {...props}>{children}</h1>,
a: ({ href, children }) => <Link href={href}>{children}</Link>,
}}
/>
);| Service | Description |
|---|---|
MdxProcessor |
Configurable markdown processor (default: remark-parse + gfm) |
MdxHighlighter |
BYO code highlighter for syntax-highlighted code blocks |
MDXComponents |
App-wide component overrides (props-level takes priority) |
// Custom processor with plugins
const processorLayer = MdxProcessor.make({
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
});
// App-wide component overrides
const componentsLayer = MDXComponents.make({
h1: ({ children, ...props }) => <h1 class="heading" {...props}>{children}</h1>,
a: ({ href, children }) => <Link href={href}>{children}</Link>,
});
// Code highlighter
const highlighterLayer = MdxHighlighter.make((code, lang) =>
<pre class={`language-${lang}`}><code innerHTML={highlight(code, lang)} /></pre>
);
render(<App />, root, { layer: Layer.mergeAll(processorLayer, componentsLayer, highlighterLayer) });JSX supports SVG elements natively. SVG-specific attributes (viewBox, fill, stroke, d, etc.) are typed and handled correctly:
const Icon = () => (
<svg viewBox="0 0 24 24" width={24} height={24}>
<path d="M12 2L2 22h20L12 2z" fill="currentColor" />
</svg>
);AtomHttpApi connects @effect/platform HTTP APIs to reactive atoms. Define an API schema once, then derive query and mutation atoms automatically:
import { AtomHttpApi } from "fibrae";
import * as HttpApi from "@effect/platform/HttpApi";
import * as FetchHttpClient from "@effect/platform/FetchHttpClient";
// Define your API
const Api = HttpApi.make("notes").add(/* ... endpoints ... */);
// Create a tagged service with query/mutation atom factories
const NotesApi = AtomHttpApi.Tag<NotesApi>()("NotesApi", {
api: Api,
httpClient: FetchHttpClient.layer,
});
// In components — derive reactive atoms from API endpoints
const postListQuery = NotesApi.query("posts", "list");
const createPostMutation = NotesApi.mutation("posts", "create");Query atoms automatically cache, deduplicate, and re-fetch. Mutation atoms manage submission state and invalidate related queries.
subscribeAtom and mountAtom tie atom subscriptions and setup effects to the component lifecycle, cleaning up automatically on unmount:
import { subscribeAtom, mountAtom } from "fibrae";
const Logger = () =>
Effect.gen(function* () {
// Subscribe for the component's lifetime
yield* subscribeAtom(countAtom, (value) => {
console.log("count changed:", value);
});
return <div>Logging active</div>;
});
const Editor = () =>
Effect.gen(function* () {
const ref = { current: null as HTMLDivElement | null };
// Run setup after mount, clean up on unmount
yield* mountAtom(
Effect.gen(function* () {
const editor = monaco.create(ref.current!);
yield* Effect.addFinalizer(() => Effect.sync(() => editor.dispose()));
}),
);
return <div ref={(el) => (ref.current = el)} />;
});| Export | Description |
|---|---|
render(element, container, options?) |
Mount a VElement tree to the DOM |
Atom |
Atom creation and utilities (from @effect-atom/atom) |
AtomRegistry |
Registry service for reading/writing atoms |
Result |
Result.initial() / Result.success(a) for async value states |
Suspense |
Threshold-based loading boundary |
ErrorBoundary |
Catches errors in subtree, shows fallback, supports navigation recovery |
ComponentScope |
Service providing { scope, mounted } for lifecycle management |
HydrationState |
Service for dehydrated state (auto-discovered from DOM) |
AtomHttpApi |
HTTP API to reactive atom bridge |
mountAtom(effect) |
Run setup effect after mount, scoped to component lifetime |
subscribeAtom(atom, callback) |
Subscribe to atom for component lifetime, auto-cleanup on unmount |
RenderError / StreamError / EventHandlerError |
Tagged error types |
| Export | Description |
|---|---|
renderToString(element) |
Render to HTML + dehydrated state (self-contained) |
renderToStringWith(element) |
Render to HTML, requiring AtomRegistry from caller |
SSRAtomRegistryLayer |
Synchronous registry layer for SSR |
| Export | Description |
|---|---|
Route.get(name, path) |
Declare a route with static path |
Route.get(name)\/path/${param}`` |
Declare a route with template literal path |
Route.param(name, schema) |
Schema-validated path parameter |
Router.make(name) |
Create a router |
Router.group(name) |
Create a route group |
Router.layout(name, basePath) |
Create a layout group |
Router.serverLayer(options) |
SSR layer (provides History, Navigator, CurrentRouteElement) |
Router.browserLayer(options) |
Client hydration layer |
RouterBuilder.group(router, name, fn) |
Implement handlers for a route group |
RouterBuilder.layoutGroup(router, name, fn) |
Implement handlers for a layout group |
Link |
Path-based link component (type-safe via RegisteredRouter) |
RouterOutlet |
Renders matched route component |
OutletDepth |
Context tag for nested outlet depth |
Navigator / NavigatorLive(router) |
Programmatic navigation service |
Form |
Declarative form with schema decode + route action |
FormState |
Service for reading submission state inside Form children |
FormValidationError |
Tagged error for schema decode failures on form data |
BrowserHistoryLive |
Browser history layer |
MemoryHistoryLive(options?) |
In-memory history layer |
| Export | Description |
|---|---|
live(event, { schema }) |
Create a live atom backed by SSE |
serve(atom, options) |
SSE endpoint for a single live atom |
serveGroup({ channels }) |
Multiplexed SSE endpoint for multiple atoms |
LiveConfig |
Client-side SSE connection configuration |
| Export | Description |
|---|---|
MDX |
Component that renders markdown as VElements |
MdxProcessor |
Configurable markdown processor service |
MdxHighlighter |
BYO code highlighter service |
MDXComponents |
App-wide component override service |
parseMdx(content) |
Parse markdown to MDAST (low-level) |
renderMdast / renderHast |
Render MDAST/HAST trees to VElements (low-level) |