Signals & Readables
Effex’s reactivity is built on two primitives: Signals (mutable reactive values) and Readables (observable values that can be derived, combined, and composed). Every reactive behavior in Effex traces back to these two types.
Signals
A Signal holds a value that can be read and written. When the value changes, anything observing it updates automatically.
import { Effect } from "effect";
import { Signal } from "@effex/dom";
const count = yield* Signal.make(0);
// Read the current value
const current = yield* count.get;
// Set a new value
yield* count.set(5);
// Update based on the current value
yield* count.update((n) => n + 1);
Signals are scoped — they’re created within an Effect and cleaned up when the scope finalizes. This means no manual teardown or memory leak worries.
Custom Equality
By default, Signals use strict equality (===) to decide if a value has changed. If the new value is the same as the old one, observers aren’t notified. You can customize this with Signal.equals:
interface User {
id: number;
name: string;
lastSeen: Date;
}
// Only trigger updates when the user ID changes
const currentUser = yield* Signal.make<User>(
{ id: 1, name: "Alice", lastSeen: new Date() }
).pipe(Signal.equals((a, b) => a.id === b.id));
This is useful when your value contains fields that change frequently but aren’t semantically meaningful — like timestamps or metadata.
From Nullable or Reactive Values
// Use an existing signal if provided, or create a new one
const value = yield* Signal.fromNullable(existingSignal, "default");
// Convert any Reactive<T> (static value or Readable) into a Signal
const editable = yield* Signal.fromReactive(props.label, "fallback");
Readables
Readables are the read-only side of reactivity. Every Signal is a Readable, but not every Readable is a Signal. You can derive new Readables from existing ones without creating new mutable state.
Derived Values
Readable.map transforms a Readable’s value:
import { Readable, Signal } from "@effex/dom";
const firstName = yield* Signal.make("John");
const lastName = yield* Signal.make("Doe");
// Derived from a single source
const upperFirst = Readable.map(firstName, (s) => s.toUpperCase());
// Combine two readables
const fullName = Readable.zipWith(
firstName,
lastName,
(first, last) => `${first} ${last}`,
);
// Combine into a tuple
const both = Readable.zipAll([firstName, lastName]);
// both: Readable<[string, string]>
Derived Readables update automatically when their sources change. There’s no manual subscription management.
Constants and Streams
// A Readable that never changes
const label = Readable.of("Hello");
// From an initial value and a stream of updates
const time = Readable.fromStream(Date.now(), clockStream);
Normalizing Props
Components often accept props that can be either static values or Readables. The Reactive<T> type represents this, and Readable.normalize handles both cases:
// Reactive<T> means: T | Readable<T>
interface ButtonProps {
disabled?: Readable.Reactive<boolean>;
class?: Readable.Reactive<string>;
}
const Button = <E, R>(props: ButtonProps, child: Child<E, R>) => {
const disabled = Readable.normalize(props.disabled ?? false);
const className = Readable.normalize(props.class ?? "");
const ariaDisabled = disabled.pipe(
Readable.map((d) => (d ? "true" : undefined)),
);
return $.button(
{ class: className, disabled, "aria-disabled": ariaDisabled },
child,
);
};
Whether the caller passes disabled={true} or disabled={someSignal}, your component handles it the same way.
Lifting Functions
If you use utility libraries like class-variance-authority or clsx, Readable.lift makes them reactive-aware:
import { cva } from "class-variance-authority";
import { Signal, Readable } from "@effex/dom";
const buttonStyles = cva("btn font-medium rounded", {
variants: {
variant: { primary: "bg-blue-500", secondary: "bg-gray-200" },
size: { sm: "px-2 py-1", md: "px-4 py-2", lg: "px-6 py-3" },
},
});
// Lift it to accept Readables in the options
const reactiveButtonStyles = Readable.lift(buttonStyles);
const variant = yield* Signal.make<"primary" | "secondary">("primary");
// className is Readable<string> — updates when variant changes
const className = reactiveButtonStyles({ variant, size: "md" });
Filtering and Deduplication
// Only emit values that pass the predicate
const positiveOnly = Readable.filter(count, (n) => n > 0);
// Skip consecutive duplicates
const deduped = Readable.dedupe(value);
// Dedupe with custom equality
const dedupedById = Readable.dedupeWith(user, (a, b) => a.id === b.id);
Side Effects
Readable.tap runs a side effect whenever the value changes, without transforming it:
const logged = Readable.tap(count, (n) => console.log("count is now", n));
Ref
For cases where you need a mutable reference that’s set later — like a DOM element ref — use Ref:
import { Ref } from "@effex/dom";
const inputRef = yield* Ref.make<HTMLInputElement>();
// inputRef.current is null until set
// inputRef.value is an Effect that resolves when the ref is populated
// Focus the input (waits until the ref is set)
yield* inputRef.value.pipe(
Effect.tap((el) => Effect.sync(() => el.focus())),
);