Reference·v6.x·TypeScript & ReScript signatures

API Reference

The public surface of tilia. Each entry gives the signature, behavior, and one minimal example.

make(gc?)

CoreSince 1.0

Create an isolated Tilia context with its own reactive world.

function make(gc?: number): Tilia
let make: (~gc: int=?, unit) => tilia

make creates a context object containing the Tilia API (tilia, carve, observe, watch, batch, signal, derived, source, store, _observe).

Each context is isolated: observers and proxies from one context do not share tracking with another context. Use this to create independent reactive worlds.

gc sets the garbage-collection threshold for cleared watchers. The default is 50. See tilia, Tilia, and the guide chapter Mistakes stay small.

Example
import { make } from "tilia";

const a = make();
const b = make();
const left = a.tilia({ count: 0 });
const right = b.tilia({ count: 0 });
left.count = 1;
right.count = 2;
open Tilia

let a = make()
let b = make()
let left = a.tilia({count: 0})
let right = b.tilia({count: 0})
left.count = 1
right.count = 2

tilia(branch)

CoreSince 2.0

Wrap an object or array in a reactive proxy.

function tilia<T>(branch: T): T
let tilia: 'a => 'a

tilia converts a plain object or array into a proxy that tracks property reads and writes. The return value keeps the same shape and type as the input.

Nested plain objects and arrays are proxied lazily when read. Values with non-plain prototypes (for example, class instances) are returned as-is. Calling tilia on a value that is not an object or array throws. Calling tilia on an already proxied value returns the same proxy.

Writing the same value (or the same underlying target object) does not notify observers. See also observe, computed, and the guide chapter A Living Object.

Example
import { tilia } from "tilia";

const alice = tilia({
  name: "Alice",
  age: 10,
});

alice.age = 11;
open Tilia

let alice = tilia({
  name: "Alice",
  age: 10,
})

alice.age = 11

carve(fn)

CoreSince 2.0

Build a reactive object where fields can derive from the full object.

function carve<T>(fn: (deriver: Deriver<T>) => T): T
let carve: (deriver<'a> => 'a) => 'a

carve builds a proxied object from a factory function, providing a deriver argument with derived(self => ...).

derived is evaluated with the carved object as its input. This enables cross-field derivations and methods that depend on sibling fields. The returned object is then proxied like tilia.

Use carve when a derivation needs self; use computed when a standalone closure is enough. See the guide chapter Carving a feature.

Example
import { carve } from "tilia";

const counter = carve(({ derived }) => ({
  value: 1,
  double: derived((self) => self.value * 2),
}));
open Tilia

let counter = carve(({derived}) => {
  value: 1,
  double: derived(self => self.value * 2),
})

observe(fn)

CoreSince 1.0

Run a tracked callback immediately and on dependency changes.

function observe(fn: () => void): () => void
let observe: (unit => unit) => unit => unit

observe runs fn once immediately, tracks reactive reads during that run, and re-runs fn whenever one of those tracked keys changes. Dependency tracking is rebuilt on each run.

Writes performed inside fn are deferred while fn is running. If fn writes to keys that it also tracks, it is scheduled to run again after the current run finishes. This makes observe suitable for state-machine-style transitions.

observe returns a function that cancels the observation. Once called, the callback never runs again. You can ignore this function for observers that should live as long as the context. For two-phase capture/effect behavior, use watch. For pull reactivity, use computed. See the guide chapters A living object and While Alice sleeps.

Example
import { observe, tilia } from "tilia";

const alice = tilia({
  name: "Alice",
  username: "alice",
});

const stop = observe(() => {
  alice.username = alice.name.toLowerCase();
});

alice.name = "Alba"; // ✨ username follows

stop();
alice.name = "Ada"; // 💤 username no longer follows
open Tilia

let alice = tilia({
  name: "Alice",
  username: "alice",
})

let stop = observe(() => {
  alice.username = alice.name->String.toLowerCase
})

alice.name = "Alba" // ✨ username follows

stop()
alice.name = "Ada" // 💤 username no longer follows

watch(fn)

CoreSince 2.1

React to captured values with an untracked effect phase.

function watch<T>(fn: () => T, effect: (v: T) => void): () => void
let watch: (unit => 'a, 'a => unit) => unit => unit

watch separates reactive work into two phases: fn captures dependencies and returns a value, and effect receives that value when the captured dependencies change.

A watch never retriggers itself through its own writes, whether they occur during capture or effect. The effect runs untracked, and notifications to other observers from its writes are deferred and flushed as a single batch. On initial registration, fn runs once to install dependencies, but effect is not called. watch returns a function that stops the watch; once called, neither phase runs again.

Use observe when you need a single tracked callback. See the guide chapter While Alice sleeps.

Example
import { signal, watch } from "tilia";

const [score, setScore] = signal(0);
const [result, setResult] = signal("pending");

watch(
  () => result.value,
  (value) => {
    if (value === "pass") setScore(score.value + 1);
  }
);

setResult("pass");
open Tilia

let (score, setScore) = signal(0)
let (result, setResult) = signal("pending")

watch(
  () => result.value,
  value => {
    if value === "pass" {
      setScore(score.value + 1)
    }
  },
)

setResult("pass")

batch(fn)

CoreSince 2.0

Group multiple writes and flush notifications once.

function batch(fn: () => void): void
let batch: (unit => unit) => unit

batch runs fn while notification flushing is locked, then flushes notifications once the outermost batch ends.

Nested batches are supported. Within a batch, writes update state immediately, but observers are notified only after the outermost batch ends. This prevents transient intermediate notifications.

observe, watch effects, and computed rebuilds already use deferred flushing. Use batch for grouped writes from non-reactive callbacks. See watch and the guide chapter While Alice sleeps.

Example
import { batch, tilia } from "tilia";

const rect = tilia({
  width: 100,
  height: 50,
});

batch(() => {
  rect.width = 200;
  rect.height = 100;
});
open Tilia

let rect = tilia({
  width: 100,
  height: 50,
})

batch(() => {
  rect.width = 200
  rect.height = 100
})

computed(fn)

CoreSince 2.0

Define a pull-based cached value for insertion into reactive objects.

function computed<T>(fn: () => T): T
let computed: (unit => 'a) => 'a

computed creates a dynamic value intended for direct assignment in a tilia/carve object. The value is computed when read, cached, and invalidated when tracked dependencies change.

If no observer depends on the computed key, Tilia can clear its internal observer and keep the dynamic definition for later reads. Replacing or deleting the property removes the previous computed behavior.

Using a computed value outside an insertion context raises an orphan-computation error. Define it directly where it is inserted. See tilia, carve, and the guide chapter Values that follow.

Example
import { computed, tilia } from "tilia";

const alice = tilia({
  birthYear: 2015,
  nowYear: 2026,
  age: computed(() => alice.nowYear - alice.birthYear),
});

alice.nowYear = 2027;
alice.age;
open Tilia

let alice = tilia({
  birthYear: 2015,
  nowYear: 2026,
  age: computed(() => alice.nowYear - alice.birthYear),
})

alice.nowYear = 2027
ignore(alice.age)

source(initVal, fn, set)

CoreSince 2.0

Define an inserted value managed by previous-plus-set setup logic.

function source<T>(initVal: T, fn: (previous: T, set: Setter<T>) => unknown): T
let source: ('a, ('a, 'a => unit) => 'ignored) => 'a

source creates a dynamic value for insertion into a reactive object. It starts with initialValue, then executes fn(previous, set) on the first read and whenever tracked dependencies in fn change.

previous is the latest value held by the source, and set updates it. If fn does asynchronous work, dependencies must still be read synchronously before awaiting; only synchronous reads are tracked.

If dependencies change, the current value remains available until set is called again. In the example below, the previous cards remain visible while the new deck loads. See store, carve, and the guide chapter Letting the world in.

Example
import { signal, source, tilia } from "tilia";

const [deckId, setDeckId] = signal("spanish");

const app = tilia({
  cards: source([], (_previous, set) => {
    const id = deckId.value; // synchronous read: tracked
    fetchCards(id).then(set);
  }),
});

setDeckId("french"); // setup re-runs, cards reload
open Tilia

let (deckId, setDeckId) = signal("spanish")

let app = tilia({
  cards: source([], (_previous, set) => {
    let id = deckId.value // synchronous read: tracked
    fetchCards(id)->Promise.thenResolve(set)->ignore
  }),
})

setDeckId("french") // setup re-runs, cards reload

Loading sentinel

When an empty result is valid, use a stable, empty tilia value to distinguish the initial loading state without changing the value's type. Compare by identity:

Example
const loading = tilia<Card[]>([]);

const app = tilia({
  cards: source(loading, (_previous, set) => {
    fetchCards("spanish").then(set);
  }),
});

if (app.cards === loading) {
  // No result has arrived yet.
}
let loading = tilia([])

let app = tilia({
  cards: source(loading, (_previous, set) => {
    fetchCards("spanish")->Promise.thenResolve(set)->ignore
  }),
})

if app.cards === loading {
  // No result has arrived yet.
}

The sentinel marks only the initial load. When a dependency changes, source keeps its previous value until the next call to set. Call set(loading) synchronously if a reload should return to the loading state.

store(fn)

CoreSince 2.0

Define an inserted managed value from a setup function with setter.

function store<T>(fn: (set: Setter<T>) => T): T
let store: (('a => unit) => 'a) => 'a

store creates a dynamic value for insertion into a reactive object. Its fn(set) setup returns the current value and receives set so that it can update the value later.

The setup runs on the first access and can run again when dependencies tracked during setup change. This is suitable for finite-state values whose transitions call set.

Use source when the setup needs the previous value. See the guide chapter Letting the world in.

Example
import { store, tilia } from "tilia";
import type { Setter } from "tilia";

type Session =
  | { t: "Idle"; start: () => void }
  | { t: "Running"; stop: () => void };

const idle = (set: Setter<Session>): Session => ({
  t: "Idle",
  start: () => set(running(set)),
});

const running = (set: Setter<Session>): Session => ({
  t: "Running",
  stop: () => set(idle(set)),
});

const app = tilia({ session: store(idle) });
open Tilia

type session = Idle({start: unit => unit}) | Running({stop: unit => unit})

let rec idle = set => Idle({start: () => set(running(set))})
and running = set => Running({stop: () => set(idle(set))})

let app = tilia({session: store(idle)})

signal(value)

CoreSince 2.0

Create a single-value reactive signal and its setter.

function signal<T>(value: T): [Signal<T>, Setter<T>]
let signal: 'a => (signal<'a>, setter<'a>)

signal returns a pair: a reactive object { value } and a setter function.

The signal object is a Tilia proxy, so reads of .value are tracked, and writes through the setter notify dependents. This provides a compact form for individual mutable values.

Use derived to build a computed signal and lift to expose a signal in a tilia object.

Example
import { signal } from "tilia";

const [count, setCount] = signal(0);
setCount(1);
count.value;
open Tilia

let (count, setCount) = signal(0)
setCount(1)
ignore(count.value)

derived(fn)

CoreSince 2.1

Create a signal whose value is computed from reactive dependencies.

function derived<T>(fn: () => T): Signal<T>
let derived: (unit => 'a) => signal<'a>

derived computes a signal's value from reactive reads in fn. The returned signal exposes the computed result at .value.

Internally, this is equivalent to creating a signal with a computed value. Consumers track signal.value like any other signal.

Use signal for manual writes, and lift to insert a derived signal into objects.

Example
import { derived, signal } from "tilia";

const [a, setA] = signal(1);
const [b, setB] = signal(2);
const sum = derived(() => a.value + b.value);

setA(3);
setB(4);
sum.value;
open Tilia

let (a, setA) = signal(1)
let (b, setB) = signal(2)
let sum = derived(() => a.value + b.value)

setA(3)
setB(4)
ignore(sum.value)

lift(s)

CoreSince 3.0

Lift a signal into a computed value for object insertion.

function lift<T>(s: Signal<T>): T
let lift: signal<'a> => 'a

lift converts a signal into an inserted computed value by tracking s.value.

It is equivalent to computed(() => s.value). Use it when an object should expose a signal as a read-only field while retaining mutation through the signal setter.

See signal, computed, and the guide chapter A small vocabulary.

Example
import { lift, signal, tilia } from "tilia";

const [title, setTitle] = signal("A");

const todo = tilia({
  title: lift(title),
  setTitle,
});

todo.setTitle("B");
todo.title;
open Tilia

let (title, setTitle) = signal("A")

let todo = tilia({
  title: title->lift,
  setTitle,
})

todo.setTitle("B")
ignore(todo.title)

readonly(data)

CoreSince 2.0

Wrap data in a non-writable holder to avoid nested tracking.

function readonly<T>(data: T): Readonly<T>
let readonly: 'a => readonly<'a>

readonly returns an object with a non-writable data property. The wrapped value is returned as-is and is not proxied for nested tracking.

Use readonly to insert large immutable data blocks into a reactive tree while preventing accidental replacement of the wrapped data field.

See tilia and the guide chapter A small vocabulary.

Example
import { readonly, tilia } from "tilia";

const app = tilia({ schema: readonly({ version: 1 }) });
app.schema.data.version;
open Tilia

let app = tilia({schema: readonly({version: 1})})
ignore(app.schema.data.version)

Observer

CoreSince 1.0

Opaque observer handle used by low-level observer lifecycle helpers.

type Observer = { readonly [o]: true }
type observer

The Observer/observer type is an opaque handle representing a registered observer.

Application code that uses public APIs does not normally create or consume this type directly. It appears in low-level lifecycle helpers (_observe, _done, _ready, _clear).

For ordinary reactive effects, use observe or watch.

Example
import type { Observer } from "tilia";

const list: Observer[] = [];
void list;
open Tilia

let list: array<observer> = []
ignore(list)

Signal

CoreSince 2.0

Mutable single-value container used by signal-based APIs.

type Signal<T> = { value: T }
type signal<'a> = {mutable value: 'a}

The Signal<T>/signal<'a> type is the value container returned by signal and derived.

Reads from value are trackable, and writes through setters update value.

See also Setter and lift.

Example
import type { Signal } from "tilia";

const s: Signal<number> = { value: 0 };
s.value = 1;
open Tilia

let s: signal<int> = {value: 0}
s.value = 1

Readonly

CoreSince 2.0

Wrapper type exposing immutable data through a data field.

type Readonly<T> = { readonly data: T }
type readonly<'a> = {data: 'a}

The Readonly<T>/readonly<'a> type is the wrapper produced by readonly.

It exposes data and prevents replacing the data property itself.

The wrapped value is not proxied for nested tracking.

Example
import type { Readonly } from "tilia";

const ro: Readonly<{ version: number }> = { data: { version: 1 } };
void ro.data.version;
open Tilia

type schema = {version: int}

let ro: readonly<schema> = {data: {version: 1}}
ignore(ro.data.version)

Setter

CoreSince 2.0

Function type that assigns a new value of the same type.

type Setter<T> = (v: T) => void
type setter<'a> = 'a => unit

The Setter<T>/setter<'a> type defines the callback shape used by signal, source, and store.

It accepts the next value and returns void/unit.

Example
import type { Setter } from "tilia";

const setCount: Setter<number> = (v) => {
  void v;
};
setCount(1);
open Tilia

let setCount: setter<int> = v => ignore(v)
setCount(1)

Deriver

CoreSince 2.0

Helper object passed to carve for self-based derivation.

type Deriver<U> = { derived: <T>(fn: (p: U) => T) => T }
type deriver<'p> = {derived: 'a. ('p => 'a) => 'a}

The Deriver<U>/deriver<'p> type is the helper parameter received by carve.

Its derived method binds the carved object as input for self-based derivations.

Example
import type { Deriver } from "tilia";

const build = (d: Deriver<{ value: number }>) => ({
  value: 1,
  double: d.derived((self) => self.value * 2),
});
void build;
open Tilia

type counter = {value: int, double: int}

let build = (d: deriver<counter>) => {
  value: 1,
  double: d.derived(self => self.value * 2),
}
ignore(build)

Tilia

CoreSince 2.0

Context object type containing one complete Tilia API surface.

type Tilia = {
  tilia: <T>(branch: T) => T,
  carve: <T>(fn: (deriver: Deriver<T>) => T) => T,
  observe: (fn: () => void) => () => void,
  watch: <T>(fn: () => T, effect: (v: T) => void) => () => void,
  batch: (fn: () => void) => void,
  signal: <T>(value: T) => [Signal<T>, Setter<T>],
  derived: <T>(fn: () => T) => Signal<T>,
  source: <T>(
    initialValue: T,
    fn: (previous: T, set: Setter<T>) => unknown
  ) => T,
  store: <T>(fn: (set: Setter<T>) => T) => T,
  _observe: (callback: () => void) => Observer
}
type tilia = {
  tilia: 'a. 'a => 'a,
  carve: 'a. (deriver<'a> => 'a) => 'a,
  observe: (unit => unit) => unit => unit,
  watch: 'a. (unit => 'a, 'a => unit) => unit => unit,
  batch: (unit => unit) => unit,
  signal: 'a. 'a => (signal<'a>, setter<'a>),
  derived: 'a. (unit => 'a) => signal<'a>,
  source: 'a 'ignored. ('a, ('a, 'a => unit) => 'ignored) => 'a,
  store: 'a. (('a => unit) => 'a) => 'a,
  _observe: (unit => unit) => observer,
}

The Tilia/tilia type is the context object returned by make.

It packages a full API set bound to one reactive root context. Proxies and observers from different contexts are isolated.

Use this type when passing a context explicitly (for example, to react make).

Example
import { make } from "tilia";
import type { Tilia } from "tilia";

const ctx: Tilia = make();
void ctx.observe;
open Tilia

let ctx: tilia = make()
ignore(ctx.observe)