Documentation

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

This documentation is for the upcoming version 4.0

If you need the documentation for previous versions, please send me an email at (g dot a dot midasum dot com) and I will update the website to display previous versions API ☺️

Installation

# Version 4.0: Code is stable API might change.

npm install tilia@beta 

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 paramPrevious valueSetterReturn value
computedComputed value from external sources❌ No❌ No❌ No✅ Yes
carveCross-property computation✅ Yes❌ No❌ No✅ Yes
sourceExternal/async updates❌ No✅ Yes✅ Yes❌ No
storeState machine/init logic❌ No❌ No✅ Yes✅ Yes
readonlyAvoid tracking on (large) readonly data

And some syntactic sugar:

Function Use-case Implementation
signal Create a mutable value and setter
const signal = (v) => {
  const s = tilia({ value: v })
  return [s, (v) => { s.value = v }]
}
let signal = v => {
  let s = tilia({ value: v })
  (s, v => s.value = v)
}
derived Creates a computed value based on other tilia values
const derived = (fn) =>
  signal(computed(fn))
let derived = fn => 
  signal(computed(fn))
lift Unwrap a signal to insert it into a tilia object
const lift = (s) => 
  computed(() => s.value)
let lift = s => 
  computed(() => s.value)

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 an inital value and 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(
    { t: "Loading" },
    (_previous, set) => {
      if (alice.age > 13) {
        fetchData(set);
      } else {
        set({ t: "NotAvailable" });
      }
    }
  ),
  // Subscription to async event (online status)
  online: source(false, subscribeOnline),
});
let app = tilia({
  // Async data (re-)loader (setup will re-run when alice's age changes.
  social: source(
    Loading,
    (_previous, set) => {
      // "social" setup will re-run when alice's age changes
      if (alice.age > 13) {
        fetchData(set)
      } else {
        set(NotAvailable)
      }
    }
  ),
  // Subscription to async event (online status)
  online: source(false, subscribeOnline),
})

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 and a setter.

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

const signal = (v) => {
  const s = tilia({ value: v })
  return [s, (v) => { s.value = v }]
}

// Usage

const [s, set] = signal(0)

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

let signal = v => {
  let s = tilia({ value: v })
  (s, v => s.value = v)
}

// Usage

let (s, set) = signal(0)

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

🌱 Small tip: Use signal for state computations and expose them with tilia and lift to reflect your domain:

// ✅ Domain-driven
const [authenticated, setAuthenticated] = signal(false)

const app = tilia({
  authenticated: lift(authenticated)
  now: store(runningTime),
});

if (app.authenticated) {
}
// ✅ Domain-driven
let (authenticated, setAuthenticated) = signal(false)

let app = tilia({
  authenticated: lift(authenticated),
  now: store(runningTime),
})

if app.authenticated {
}

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)

lift

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

// Lift implementation
function lift<T>(s: Signal<T>): T {
  return computed(() => s.value);
}

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

const (title, setTitle) = signal("");

const todo = tilia({
  title: lift(title),
  setTitle,
});
// Lift implementation
let lift = s => computed(() => s.value)

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

let [title, setTitle] = signal("")

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

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(initialValue, machine));
derived(tree => source(initialValue, machine))

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.

leaf (React Higher Order Component)

This is the favored way of making reactive components. Compared to useTilia, this tracking is exact due to proper begin/end tracking of the render phase which is not doable with hooks.

Installation

npm install @tilia/react

Wrap your component with leaf:

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

// Use a named function to have proper component names in React dev tools.
const App = leaf(function App() {
  if (alice.age >= 13) {
    return <SocialMediaApp />;
  } else {
    return <NormalApp />;
  }
});
open TiliaReact

@react.component
let make = leaf(() => {
  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.