Component Authoring
Weft components are plain TypeScript functions that return a Node<E, R>. This guide covers the two ways to define them and when to choose each.
Plain functions
The simplest component is just a function:
import { h } from "@weftui/core";
function Greeting({ name }: { name: string }) {
return h.p(`Hello, ${name}!`);
}
// Call it like a function
Greeting({ name: "World" });Use a plain function when:
- Props are all static (strings, numbers, plain functions)
- The component has no internal state
- You don't need the caller's reactive prop types to propagate
Components with internal state
When a component needs reactive state, use Effect.gen to set it up before building the tree. The component function still runs once — the setup happens at mount time:
import { h } from "@weftui/core";
import { Effect, SubscriptionRef } from "effect";
const Counter = () =>
Effect.gen(function* () {
const count = yield* SubscriptionRef.make(0);
return yield* h.div([
h.span([count.changes]),
h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
]);
});The return type here is Effect.Effect<Node, never, never> — itself a valid Node, so it composes naturally with other tree-building calls. As soon as such a component is reused or takes props, prefer wrapping the same generator in Component.gen (below) so the caller's reactive prop and children channels flow into its node type.
Component scope and background effects
Every component instance is rendered under its own instance scope — a child of the
mount scope created fresh for that instance. Anything bound to the instance scope lives
exactly as long as the component is mounted and is torn down automatically when the
component unmounts (or when the whole tree unmounts via the MountHandle). The renderer
provides this scope as the ambient Scope.Scope while it evaluates the component body,
so it is already in context when you need it.
This matters the moment a component starts background work — a subscription, an
observer of a ref, a polling timer, anything you fork. The rule:
Fork background work with
Effect.forkScoped, never a bareEffect.fork.
Effect.forkScoped attaches the fiber to the instance scope, so it keeps running for
the component's lifetime and is interrupted on unmount. A bare Effect.fork instead
attaches the fiber to the component-body fiber — the one that runs your Effect.gen to
produce the tree. That fiber completes the instant the gen returns its node, so the
forked work is cancelled almost immediately.
Concretely, an observer that runs an effect when a ref's element mounts:
import { h } from "@weftui/core";
import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
const AutoFocusInput = () =>
Effect.gen(function* () {
const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
yield* pipe(
inputRef.changes,
Stream.filter(Option.isSome),
Stream.take(1),
Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
Effect.forkScoped, // ✅ tied to the instance scope — survives until unmount
// Effect.fork, // ❌ tied to the body fiber — interrupted when the gen returns
);
return yield* h.input({ ref: inputRef, type: "text" });
});You do not manage the scope yourself: you do not create it, close it, or pass it
around. forkScoped reads it from context, and unmount closes it for you. If you ever
fork outside a component body (rare), you must supply a Scope.Scope yourself — the
type system will tell you, because forkScoped carries a Scope.Scope requirement.
See examples/element-ref for the auto-focus, measure, and canvas recipes built on
this pattern.
Component.gen / Component.make for reusable components
When you want the caller's reactive prop types to flow into the returned node's type, use one of the Component factories. Both have the same call semantics; pick the body style that fits:
Component.make— body is a plain function returning anyEffect(typically aNode). Use for one-liners and pipe compositions.Component.gen— body is a generator. Use when you needyield*to set up local state or pull from services.
import { Component, h, Source } from "@weftui/core";
interface CardProps {
title: Source.Source<string>;
body?: Source.Source<string>;
}
const Card = Component.make((props: CardProps) =>
h.div({ class: "card" }, [
h.h3({ class: "card-title" }, [props.title]),
props.body ? h.p({ class: "card-body" }, [props.body]) : null,
]),
);Source.Source<string> is Weft's caller-facing prop vocabulary — a single type covering a static string, a Stream<string>, an Effect<string>, or a Subscribable<string> — so you don't hand-write string | Stream.Stream<string> | … on every prop. Passing a Source straight to h (as above) is all you need when the value is just spliced into the tree; the renderer normalizes it.
Now the caller's stream types are visible in the returned node:
declare const titleStream: Stream.Stream<string, never, I18nService>;
// Node<never, I18nService> — I18nService requirement flows out
const card = Card({ title: titleStream });Without a Component factory, a plain function's return type is fixed at definition time and won't reflect the caller's reactive prop types.
Body E/R inference
You don't declare the body's E/R channels explicitly — they're inferred from the returned (or yielded) effect:
- The body's
E/Rcome from whatever effects appear inside. - The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
- Static prop values (
string,number, plain functions) contributenever.
Children: array or function
Both factories accept an optional second children argument, typed as:
type Component.Children<Input = never> =
| readonly Renderable[]
| ((input: Input) => readonly Renderable[]);The function form is the render-prop / slot pattern — the component invokes the function with whatever input it chooses, and the returned array's E/R propagate out:
const ItemList = Component.make(
(props: { items: readonly string[] }, renderItem: (item: string) => readonly Renderable[]) =>
h.ul(props.items.flatMap(renderItem)),
);
ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);Props typing
For a prop that accepts both static and reactive values, type it as Source.Source<T> rather than hand-writing the union. Source.Source<T> is that union — T | Stream<T> | Effect<T> | Subscribable<T> — so the caller can pass a plain value or any reactive shape interchangeably, and you write it once:
import { Source } from "@weftui/core";
interface ButtonProps {
label: Source.Source<string>; // static or reactive text
disabled?: Source.Source<boolean>; // static or reactive boolean
onclick?: () => void | Effect.Effect<void>; // plain or Effect-returning handler
}When a caller passes a plain string, the component's node type has never for that prop's channels. When they pass a Stream.Stream<string, never, SomeService>, SomeService appears in the R channel — the extraction is exactly Source.Success / Source.Error / Source.Context.
Reading a Source in the body
Splicing a Source straight into h ([props.label]) is enough when you only place it in the tree. When the body needs to read or derive from the value — combine two props, feed a stream operator, drive logic — normalize it first with Source.toSubscribable, which turns any Source<A> into an await-first, hot Subscribable<A>:
import { Component, h, Source } from "@weftui/core";
import { Stream } from "effect";
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
// Now derive from it like any Subscribable — static, Effect, and Stream inputs all work.
return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
});toSubscribable is scoped: a Stream prop is pumped by a fiber that terminates with the component's instance scope, an Effect prop is memoized, an existing Subscribable is threaded through by reference, and a static value emits once. It is the same normalization the renderer applies to props internally — reach for it whenever you need the value as a Subscribable instead of leaving it opaque.
Composing components
Call component functions directly inside a children array:
import { h } from "@weftui/core";
function App() {
return h.div({ class: "app" }, [
Header({ title: "My App" }),
h.main([Sidebar(), h.article([Content({ id: 1 })])]),
Footer(),
]);
}Children arrays accumulate E/R from all their members. The parent node's type reflects the union of all children's channels.
Components that require services
If a component's render function uses a service via yield*, that service appears in the component's CompR parameter:
import { Component, h } from "@weftui/core";
const UserAvatar = Component.gen(function* (props: { userId: string }) {
const userService = yield* UserService;
const user = yield* userService.getUser(props.userId);
return yield* h.img({ src: user.avatarUrl, alt: user.name });
});
// Node<never, UserService> — regardless of what the caller passes
const avatar = UserAvatar({ userId: "123" });Provide the service at the mount boundary:
void Effect.runPromise(
mount(App(), document.getElementById("root")!).pipe(Effect.provide(UserServiceLive)),
);Returning fragments
When a component needs to return multiple sibling elements without a wrapper, use h.fragment:
import { h } from "@weftui/core";
const TableCells = ({ row }: { row: Row }) =>
h.fragment([h.td(row.name), h.td(row.value), h.td(row.status)]);h.fragment returns a Node<E, R> that accumulates channels from all its children.
See also
- The Combinator API —
h,h.fragment, and howE/Raccumulate - Reactive Primitives — the
Sourcevocabulary props accept - Add Routing — route components are
Componentslots @weftui/corereference —Component,Source, and the full surface