Reactive Primitives
The unified Source vocabulary is what lets static values, Effects, Streams, and Subscribables be used interchangeably wherever reactivity is supported — props, children, and style values all accept the same type.
Weft accepts a Source for prop values and children. Any of these is valid wherever reactivity is supported:
- A plain static value (
string,number,boolean, ...) - An
Effect.Effect<A, E, R>— runs once and resolves to a value - A
Stream.Stream<A, E, R>— each emission replaces the previous value - A
Subscribable<A, E, R>— like a hot stream; already has a "current value"
The Source<A, E, R> type captures this union:
type Source<A, E, R> = A | Effect.Effect<A, E, R> | Stream.Stream<A, E, R> | Subscribable<A, E, R>;Static values
Static props behave exactly as you'd expect — set once and never updated:
h.div({ class: "container", id: "root" }, "Hello");Effect props
When a prop value is an Effect, it runs once and the resulting value is applied:
const username = Effect.map(fetchProfile(), (p) => p.name);
// Renders the username once it resolves
h.span([username]);The E and R channels of the Effect flow into the node's own channels.
Stream props and children
Streams are the primary reactive primitive. Each emission replaces the previous value in the DOM — no diffing, direct DOM update:
import { SubscriptionRef, Stream } from "effect";
const count = yield * SubscriptionRef.make(0);
// count.changes is a Stream<number> — each new value updates the text node
h.span([count.changes]);
// Stream as a prop — each emission sets the attribute
const isDisabled = Stream.map(count.changes, (n) => n >= 10);
h.button({ disabled: isDisabled }, "Submit");Streams can also supply entire child arrays. Each emission replaces the previous set of children:
const todos = yield * SubscriptionRef.make<string[]>([]);
h.ul([Stream.map(todos.changes, (list) => list.map((item) => h.li(item)))]);Derived streams
Because .changes is a plain Stream, the full Stream API applies:
const count = yield * SubscriptionRef.make(0);
const doubled = Stream.map(count.changes, (n) => n * 2);
const formatted = Stream.map(count.changes, (n) => `Count: ${n}`);
const isHigh = Stream.map(count.changes, (n) => n > 10);
h.div([
h.p([count.changes]),
h.p([doubled]),
h.p([formatted]),
h.p({ style: { color: Stream.map(isHigh, (b) => (b ? "red" : "black")) } }, "Status"),
]);Multiple refs can be combined with Stream.zipLatestWith, Stream.merge, or other combinators:
const firstName = yield * SubscriptionRef.make("");
const lastName = yield * SubscriptionRef.make("");
const fullName = Stream.zipLatestWith(firstName.changes, lastName.changes, (first, last) =>
`${first} ${last}`.trim(),
);Reactive styles
The style prop accepts the same Source vocabulary at any level:
// Individual property as a stream
h.div({
style: {
color: colorStream, // Stream<string>
opacity: opacityStream, // Stream<number>
fontWeight: "bold", // static
},
});
// Entire style object as a stream
h.div({ style: styleObjectStream });
// Spread a stream into a style object (static props win over emitted props)
h.div({
style: {
...styleObjectStream, // reactive
transition: "all 0.3s", // static, always applied
},
});NoPropValue
When a Stream prop ends before emitting, the renderer raises a NoPropValue tagged error. This carries an optional key field identifying which prop triggered it:
import { NoPropValue } from "@weftui/core";
// Handle at the mount boundary if needed
pipe(
mount(App(), root),
Effect.catchTag("NoPropValue", (e) =>
Effect.logWarning(`Prop stream ended before emitting: ${e.key}`),
),
);In practice you only encounter NoPropValue if you use a finite Stream as a prop and it ends before emitting — e.g., Stream.empty or Stream.take(0, stream). Most usage with SubscriptionRef.changes or infinite streams never raises it.
See also
- The Rendering Model — streams as the weft woven through a static tree
- The Combinator API — how reactive props and children contribute
E/R - Style Reactively and Render Keyed Lists — reactive props and collections in practice
Sourcereference — theSourcetype andSource.toSubscribable