tilia

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 for uncorrelated reactive worlds.

gc sets the cleared-watcher garbage-collection threshold. Default is 50. See tilia, Tilia, and 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 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 and provides a deriver argument with derived(self => ...).

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

Use carve when derivation needs self; use computed when a standalone closure is enough. See 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 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. Ignore it for observers that should live as long as the context. For two-phase capture/effect behavior, use watch. For pull reactivity, use computed. See 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; effect receives that value when captured dependencies change.

A watch never re-triggers itself from its own writes, in capture or effect; the effect runs untracked, and its writes notify other observers deferred, as one batch. On initial registration, fn runs once to install dependencies and effect is not called. watch returns a function that stops the watch: once called, neither phase runs again.

Use observe when a single tracked callback is needed. See 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 with notification flushing locked, then flushes once when the outermost batch ends.

Nested batches are supported. While inside a batch, writes update state immediately but observers are notified only after unlock. This prevents transient intermediate notifications.

observe, watch effects, and computed rebuilds already run under deferred flushing. Use batch for grouped writes from non-reactive callbacks. See watch and 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 to be assigned directly in a tilia/carve object. The value is computed on 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 insertion context raises an orphan-computation error. Define it directly where it is inserted. See tilia, carve, and 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 in a reactive object. It starts from initialValue, then executes fn(previous, set) on 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 stays available until set is called again — below, the previous cards stay visible while the new deck loads. See store, carve, and 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. On a dependency change, 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 inserted value from fn(set). The setup returns the current value and receives set to update it later.

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

Use source when setup needs the previous value. See 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. It is a compact form for single 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 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), and is used when an object should expose a signal as a read-only field while keeping mutation through the signal setter.

See signal, computed, and 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 this to insert large immutable data blocks into a reactive tree while preventing accidental replacement of the wrapped data field.

See tilia and 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

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

Application code using public APIs normally does not 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}

Signal<T>/signal<'a> 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}

Readonly<T>/readonly<'a> 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

Setter<T>/setter<'a> is 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}

Deriver<U>/deriver<'p> is the helper parameter type 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,
}

Tilia/tilia 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 into 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)

leaf(fn)

ReactSince 3.0

Wrap a React component with exact render dependency tracking.

function leaf<T, U>(fn: (p: T) => U): (p: T) => U
let leaf: ('a => 'b) => 'a => 'b

leaf wraps a component so reads of Tilia proxies during render are tracked with exact render boundaries.

When tracked keys change, the wrapped component re-renders. The API is equivalent to a higher-order component and is the preferred React integration over useTilia.

See guide chapter tilia in React and useComputed.

Example
import { leaf } from "@tilia/react";

const Counter = leaf(() => {
  return <p>{app.count}</p>;
});
open TiliaReact

@react.component
let make = leaf(() => {
  <p> {app.count->React.int} </p>
})

useTilia()

ReactSince 2.0

Track reactive reads in a React component render.

function useTilia(): void
let useTilia: unit => unit

useTilia enables reactive tracking for the current component render. Call it at the top of the component.

Reads of Tilia proxies during render become dependencies. When one of those dependencies changes, the component re-renders. useTilia is the hook form; leaf is the preferred wrapper when possible.

See guide chapter tilia in React and related hook useComputed.

Example
import { useTilia } from "@tilia/react";

function Counter() {
  useTilia();
  return <p>{app.count}</p>;
}
open TiliaReact

@react.component
let make = () => {
  useTilia()
  <p> {app.count->React.int} </p>
}

useComputed(fn)

ReactSince 2.0

Compute a React value and re-render only when the result changes.

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

useComputed evaluates fn in reactive tracking and returns its result.

The hook compares computed results and re-renders when the result value changes. This differs from plain render reads, which depend on every tracked key read during render.

Use with useTilia or leaf in React components. See guide chapter tilia in React.

Example
import { useComputed, useTilia } from "@tilia/react";

function TodoRow({ todo }: { todo: { id: string } }) {
  useTilia();
  const selected = useComputed(() => app.selectedId === todo.id);
  return <div className={selected ? "selected" : ""} />;
}
open TiliaReact

@react.component
let make = (~todo) => {
  useTilia()
  let selected = useComputed(() => app.selectedId === todo.id)
  <div className={selected ? "selected" : ""} />
}

make(tilia)

ReactSince 1.0

Create a React API bound to a specific Tilia context.

function make(tilia: Tilia): TiliaReact
let make: tilia => tilia_react

@tilia/react make builds a React integration object (useTilia, useComputed, leaf) from a provided core Tilia context.

Use this with core make when an application needs isolated, uncorrelated reactive worlds.

The package-level exports are the default-context version; this function is the context-bound version.

Example
import { make as makeCore } from "tilia";
import { make as makeReact } from "@tilia/react";

const ctx = makeCore();
const reactApi = makeReact(ctx);
void reactApi.useTilia;
let ctx = Tilia.make()
let reactApi = TiliaReact.make(ctx)
ignore(reactApi.useTilia)

TiliaReact

ReactSince 2.0

Context-bound React integration surface for Tilia.

type TiliaReact {
  useTilia: () => void;
  useComputed: <T>(fn: () => T) => T;
  leaf: <T, U>(fn: (p: T) => U) => (p: T) => U
}
type tilia_react = {
  useTilia: unit => unit,
  useComputed: 'a. (unit => 'a) => 'a,
  leaf: 'a 'b. ('a => 'b) => 'a => 'b
}

TiliaReact/tilia_react is the React API object shape returned by react make.

It groups useTilia, useComputed, and leaf for one core context.

See leaf, useTilia, and useComputed.

Example
import { make as makeCore } from "tilia";
import { make as makeReact } from "@tilia/react";
import type { TiliaReact } from "@tilia/react";

const reactApi: TiliaReact = makeReact(makeCore());
void reactApi.leaf;
open TiliaReact

let reactApi: tilia_react = make(Tilia.make())
ignore(reactApi.leaf)