Weftv0.23.1
GitHub

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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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