Documentation

Complete guide to using Tilia for simple and fast state management in TypeScript and ReScript applications.

Installation

Use the beta version (not yet released API mostly stable):

npm install tilia

Goals and Non-goals

The goal of Tilia is to provide a minimal and fast state management solution that supports domain-oriented development (such as Clean or Diagonal Architecture). Tilia is designed so that your code looks and feels like domain logic, rather than being cluttered with library-specific details.

Non-goal Tilia is not a framework.

API Reference

tilia

Transform an object or array into a reactive tilia value.

import { tilia } from "tilia";

const alice = tilia({
  name: "Alice",
  birthday: dayjs("2015-05-24"),
  age: 10,
});
open Tilia

let alice = tilia({
  name: "Alice",
  birthday: dayjs("2015-05-24"),
  age: 10,
})

Alice can now be observed. Who knows what she will be doing?

observe

Use observe to monitor changes and react automatically. When an observed value changes, your callback function is triggered (push reactivity).

During the callback’s execution, Tilia tracks which properties are accessed in the connected objects and arrays. The callback always runs at least once when observe is first set up.

import { observe } from "tilia";

observe(() => {
  console.log("Alice is now", alice.age, "years old !!");
});

alice.age = 11; // ✨ This triggers the observe callback
open Tilia

observe(() => {
  Js.log3("Alice is now", alice.age, "years old !!")
})

alice.age = 11; // ✨ This triggers the observe callback

📖 Important Note: If you mutate an observed tilia value during the observe call, the callback will be re-run as soon as it ends.

Now every time alice’s age changes, the callback will be called.

watch

Use watch similarly to observe, but with a clear separation between the capture phase and the effect phase. The capture function observes values, and the effect function is called when the captured values change.

import { watch } from "tilia";

watch(
  () => exercise.result,
  (r) => {
    if (r === "Pass") {
      // The effect runs only when `exercise.result` changes, not when
      // `alice.score` changes because the latter is not captured.
      alice.score = alice.score + 1;
    } else if (r === "Fail") {
      alice.score = alice.score - 1;
    }
  }
);

// ✨ This triggers the effect
exercise.result = "Pass";
// 😴 This does not trigger the effect
alice.score = alice.score + 10;
open Tilia

watch(
  () => exercise.result,
  r => switch r {
      // The effect runs only when `exercise.result` changes, not when
      // `alice.score` changes because the latter is not captured.
    | Pass => alice.score = alice.score + 1
    | Fail => alice.score = alice.score - 1
    | Pending => ()
  }
)

// ✨ This triggers the effect
exercise.result = "Pass";
// 😴 This does not trigger the effect
alice.score = alice.score + 10;

📖 Note: If you mutate an observed tilia value in the capture or effect function, the callback will not be re-run and this change will be ignored.

Now every time alice finishes an exercise, her score updates.

batch

Group multiple updates to prevent redundant notifications. This can be required for managing complex update cycles—such as in games—where atomic state changes are essential.

💡 Pro tip batch is not required in computed, source, store, observe or watch where notifications are already blocked.

import { batch } from "tilia";

network.subscribe((updates) => {
  batch(() => {
    for (const update in updates) {
      app.process(update);
    }
  });
  // ✨ Notifications happen here
});
open Tilia

network.subscribe(updates => {
  batch(() => {
    for update in updates {
      app.process(update)
    }
  })
  // ✨ Notifications happen here
})

Functional Reactive Programming

Rainbow architect, tilia has 7 more functions for you! ✨

Before introducing each one, let us show you an overview.

FunctionUse-caseTree paramSetterReturn value
computedComputed value from external sources❌ No❌ No✅ Yes
carveCross-property computation✅ Yes❌ No✅ Yes
sourceExternal/async updates❌ No✅ Yes❌ No
storeState machine/init logic❌ No✅ Yes✅ Yes
readonlyAvoid tracking on (large) readonly data

And some syntactic sugar:

FunctionUse-caseImplementation
signal*Holds a mutable valuev => tilia({ value: v })
derivedCreates a computed value based on other tilia valuesfn => signal(computed(fn))
unwrap*Unwrap a signal to insert it into a tilia objects => computed(() => s.value)

* These methods are changing in version 3.0.0 (currently in beta).

computed

Return a computed value to be inserted in a Tilia object.

The value is computed when the key is read (pull reactivity) and is destroyed (invalidated) when any observed value changes.

import { computed } from "tilia";

const globals = tilia({ now: dayjs() });

setInterval(() => (globals.now = dayjs()), 1000 * 60);

const alice = tilia({
  name: "Alice",
  birthday: dayjs("2015-05-24"),
  // The value 'age' is always up-to-date
  age: computed(() => globals.now.diff(alice.birthday, "year")),
});
open Tilia
open Day

let globals = tilia({ now: now() })
setInterval(() => globals.now = now(), 1000 \* 60)

let alice = tilia({
  name: "Alice",
  birthday: dayjs("2015-05-24"),
  age: 0,
})
alice.age = computed(() => globals.now->diff(alice.birthday, "year"))

Nice, the age updates automatically, Alice can grow older :-)

💡 Pro tip: The computed can be created anywhere but only becomes active inside a Tilia object or array.

Once a value is computed, it behaves exactly like a regular value until it is expired due to a change in the dependencies. This means that there is nearly zero overhead for computed values acting as getters.

source

Return a reactive source to be inserted into a Tilia object.

A source is similar to a computed, but it receives a setter function and does not return a value. The setup callback is called on first value read and whenever any observed value changes. The initial value is used before the first set call.

const app = tilia({
  // Async data (re-)loader (setup will re-run when alice's age changes.
  social: source(
    (set) => {
      if (alice.age > 13) {
        fetchData(set);
      } else {
        set({ t: "NotAvailable" });
      }
    },
    { t: "Loading" }
  ),
  // Subscription to async event (online status)
  online: source(subscribeOnline, false),
});
let app = tilia({
  // Async data (re-)loader (setup will re-run when alice's age changes.
  social: source(
    set => {
      // "social" setup will re-run when alice's age changes
      if (alice.age > 13) {
        fetchData(set)
      } else {
        set(NotAvailable)
      }
    },
    Loading
  ),
  // Subscription to async event (online status)
  online: source(subscribeOnline, false),
})

The see different uses of source, store and computed, you can have a look at the todo app.

store

Return a computed value, created with a setter that will be inserted in a Tilia object.

import { computed } from "tilia";

const app = tilia({
  auth: store(loggedOut),
});

function loggedOut(set: Setter<Auth>): Auth {
  return {
    t: "LoggedOut",
    login: (user: User) => set(loggedIn(set, user)),
  };
}

function loggedIn(set: Setter<Auth>, user: User): Auth {
  return {
    t: "LoggedIn",
    user: User,
    logout: () => set(loggedOut(set)),
  };
}
open Tilia

let loggedOut = set => LoggedOut({
  login: user => set(loggedIn(set, user)),
})

let loggedIn = (set, user) => LoggedIn({
  user: User,
  logout: () => set(loggedOut(set)),
})

let app = tilia({
  auth: store(loggedOut),
})

💡 Pro tip: store is a very powerful pattern that makes it easy to initialize a feature in a specific state (for testing for example).

readonly

A tiny helper to mark a field as readonly (and thus not track changes to its fields):

import { type Readonly, readonly } from "tilia";

const app = tilia({
  form: readonly(bigStaticData),
});

// Original `bigStaticData` without tracking
const data = app.form.data;

// 🚨 'set' on proxy: trap returned falsish for property 'data'
app.form.data = { other: "data" };
open Tilia

let app = tilia({
  form: readonly(bigStaticData),
})

// Original `bigStaticData` without tracking
let data = app.form.data

// 🚨 'set' on proxy: trap returned falsish for property 'data'
app.form.data = { other: "data" }

signal

A signal represents a single, changing value of any type.

This is a tiny wrapper around tilia to expose a single, changing value.

In version 3.0.0 (currently in beta), the signal function returns a signal and setter.

type Signal<T> = { value: T };

function signal<T>(value: T): Signal<T> {
  return tilia({ value });
}

// Usage

const s = signal(0);

s.value = 1;
console.log(s.value);
type signal<'a> = {mutable value: 'a}

let signal = value => tilia({value: value})

// Usage

let s = signal(0)

s.value = 1
Js.log(s.value)

🌱 Small tip: Using tilia with your own field names is usually prefered to signal as it reflects your domain:

// ✅ Domain-driven
const app = tilia({
  authenticated: false,
  now: store(runningTime),
});

if (app.authenticated) {
}

// 🌧️ Less readable
const authenticated_ = signal(false);
const now_ = signal(store(runningTime));

if (authenticated_.value) {
}
// ✅ Domain-driven
let app = tilia({
  authenticated: false,
  now: store(runningTime),
})

if app.authenticated {
}

// 🌧️ Less readable
let authenticated_ = signal(false)
let now_ = signal(store(runningTime))

if (authenticated_.value) {
}

derived

Create a signal representing a computed value. This is similar to the derived argument of carve, but outside of an object.

function derived<T>(fn: () => T): Signal<T> {
  return signal(computed(fn));
}

// Usage

const s = signal(0);

const double = derived(() => s.value * 2);
console.log(double.value);

let derived = fn => signal(computed(fn))

// Usage

let s = signal(0)
let double = derived(() => s.value * 2)
Js.log(double.value)

unwrap

Create a computed value that reflects the current value of a signal to be inserted into a Tilia object. Use signal and unwrap to create private state and expose values as read-only.

Renamed to lift in version 3.0.0 (currently in beta).

function unwrap<T>(s: Signal<T>): T {
  return computed(() => s.value);
}

// Usage
type Todo = {
  readonly title: string;
  setTitle: (title: string) => void;
};

const s = signal("");

const todo = tilia({
  title: unwrap(s),
  setTitle: (title) => {
    // todo.title will reflect the new title
    s.value = title;
  },
});
let unwrap = s => computed(() => s.value)

// Usage
type todo = {
  title: string,
  setTitle: title => unit,
}

let s = signal("")

let todo = tilia({
  title: unwrap(s),
  setTitle: title => {
    // todo.title will reflect the new title
    s.value = title
  },
})

Carving

carve

This is where Tilia truly shines. It lets you build a domain-driven, self-contained feature that is easy to test and reuse.

open Tilia

let feature = carve(({ derived }) => { ... fields })

The derived function in the carve argument is like a computed but with the object itself as first parameter.

Example

import { carve, source } from "tilia";

// A pure function for sorting todos, easy to test in isolation.
function list(todos: Todos) {
  const compare = todos.sort === "by date"
    ? (a, b) => a.createdAt.localeCompare(b.createdAt)
    : (a, b) => a.title.localeCompare(b.title);
  return [...todos.data].sort(compare);
}

// A pure function for toggling a todo, also easily testable.
function toggle({ data, repo }: Todos) {
  return (id: string) => {
    const todo = data.find(t => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
      repo.save(todo)
    } else {
      throw new Error(`Todo ${id} not found`);
    }
  };
}

// Injecting the dependency "repo"
function makeTodos(repo: Repo) {
  // ✨ Carve the todos feature ✨
  return carve({ derived }) => ({
    sort: "by date",
    list: derived(list),
    data: source(repo.fetchTodos, []),
    toggle: derived(toggle),
    repo,
  });
}
open Tilia

// A pure function for sorting todos, easy to test in isolation.
let list = todos =>
  todos->Array.toSorted(switch todos.sort {
    | ByDate => (a, b) => String.compare(a.createdAt, b.createdAt)
    | ByTitle => (a, b) => String.compare(a.title, b.title)
  })

// A pure function for toggling a todo, also easily testable.
let toggle = ({ data, repo }: Todos.t) =>
  switch data->Array.find(t => t.id === id) {
    | None => raise(Not_found)
    | Some(todo) =>
      todo.completed = !todo.completed
      repo.save(todo)
  }

// Injecting the dependency "repo"
let makeTodos = repo =>
  // ✨ Carve the todos feature ✨
  carve(({ derived }) => {
    sort: ByDate,
    list: derived(list),
    data: source(repo.fetchTodos, []),
    toggle: derived(toggle),
  })

💡 Pro tip: Carving is a powerful way to build domain-driven, self-contained features. Extracting logic into pure functions (like list and toggle) makes testing and reuse easy.

Recursive derivation (state machines)

For recursive derivation (such as state machines), use source:

derived((tree) => source(machine, initialValue));
derived(tree => source(machine, initialValue))

This allows you to create dynamic or self-referential state that reacts to changes in other parts of the tree.

💡

Difference from computed

Look at todos.ts for an example of using carve to build the todos feature.

React Integration

useTilia (React Hook)

Installation

npm install @tilia/react

Insert useTilia at the top of the React components that consume tilia values.

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

function App() {
  useTilia();

  if (alice.age >= 13) {
    return <SocialMediaApp />;
  } else {
    return <NormalApp />;
  }
}
open TiliaReact

@react.component
let make = () => {
  useTilia()

  if (alice.age >= 13) {
    <SocialMedia />
  } else {
    <NormalApp />
  }
}

The App component will now re-render when alice.age changes because “age” was read from “alice” during the last render.

useComputed (React Hook)

useComputed lets you compute a value and only re-render if the result changes.

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

function TodoView({ todo }: { todo: Todo }) {
  useTilia();

  const selected = useComputed(() => app.todos.selected.id === todo.id);

  return <div className={selected.value ? "text-pink-200" : ""}>...</div>;
}
open TiliaReact

@react.component
let make = () => {
  useTilia()

  let selected = useComputed(() => app.todos.selected.id === todo.id)

  <div className={selected.value ? "text-pink-200" : ""}>...</div>;
}

With this helper, the TodoView does not depend on app.todos.selected.id but on selected.value. This prevents the component from re-rendering on every change to the selected todo.

Compared with... GitHub

Main Features

Zero dependencies
Optimized for stability and speed
Highly granular reactivity
Combines pull and push reactivity
Tracking follows moved or copied objects
Compatible with ReScript and TypeScript
Optimized computations (no recalculation, batch processing)
Tiny footprint (8KB) ✨

Why Tilia Helps with Domain-Driven Design

Domain-Driven Design (DDD) is a methodology that centers software around the core business domain, using a shared language between developers and domain experts, and structuring code to reflect real business concepts and processes123. Tilia’s design and features directly support these DDD goals in several ways:

In summary: Tilia’s minimal, expressive API and focus on modeling state and logic directly in the language of your business domain make it an excellent fit for domain-driven design. It helps you produce code that is understandable, maintainable, and closely aligned with business needs—while making it easier to manage complexity and adapt to change123.

References
1 Domain-Driven Design Glossary
2 The Pros and Cons of Domain-Driven Design
3 Domain-Driven Design: Core Principles
4 Domain-Driven Design: how to apply it in my organization?

Examples

You can check the todo app for a working example using TypeScript.

Look at tilia tests for working examples using ReScript.

Changelog

2025-07-13 2.0.0 (beta version)

  • Moved core to "tilia" npm package.
  • Changed make signature to build tilia context.
  • Enable forest mode to observe across separated objects.
  • Add computed to compute values in branches.
  • Moved observe into tilia context.
  • Added signal, and source for FRP style programming.
  • Added carve for derivation.
  • Simplify useTilia signature.
  • Add garbage collection to improve performance.

See the full changelog in the README.