Documentation
Complete guide to using Tilia for simple and fast state management in TypeScript and ReScript applications.
This documentation is for version 5.x and 4.x
Branches 5.x and 4.x have the same API.
For TypeScript, you should use the latest version.
For ReScript 11, use version 4.
Installation
npm install tilia
# If you are using tilia with React
npm install @tilia/react
For LLMs / AI coding assistants
Tilia was built to help your projects grow while staying maintainable and readable wether you prefer typin’ o vibin’.
Use the official Tilia LLM docs index:
- https://tiliajs.com/llms.txt
It links to:
- ReScript patterns
- TypeScript patterns
- carve to build self-contained features
- derived to build reactivity from pure functions
You can also directly copy llms-rescript.md or llms-typescript.md into your project or workspace rules (Knowledge tab on Lovable for example).
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 Architecture or Diagonal Architecture). Tilia is designed so that your code looks and behaves like business logic, rather than being cluttered with library-specific details.
Since this documentation is about the glue to make the code alive, it can feel that you will end up with a lot of library logic in your code. This is absolutely not the case. Tilia helps you build entire applications with pure functions and lean views.
Non-goal Tilia is not a framework.
The main idea
When building an application, it helps to think in terms of features. We talk with clients, business analysts, and end users and come up with a need.
By building an application into separate features (and roles), we help make it maintainable both by humans and AI.
The rule I use for building apps is to separate into three “categories”:
- repo The persistence layer. In this folder, there is one “carved” object for each data type that is saved.
- features The business logic. Here, every feature gets it’s own “carved” object.
In both of these, technical “connectors” to the outside world (such as translations, WebAudio, Supabase wrappers) are written into a service file that is injected into the feature (or repo).
Here is real world example of a settings feature for lea.monster (a training focus app built with tilia).
// feature/settings/index.ts
import { loader, update } from "./actions";
export const settingsBranch = (service: SettingsService, auth: AuthState) =>
carve<SettingsRepo>(({ derived }) => ({
userId: computed(() => auth.userId),
data: source({ ...DEFAULT_PREFERENCES }, derived(loader(service))),
update: derived(update(service)),
}));
// feature/settings/index.ts
// Please switch to typescript for the example.
// feature/settings/actions.ts
import type { SettingsService } from "./service";
import type { SettingsRepo, UserPreferences } from "./type";
import { DEFAULT_PREFERENCES } from "./type";
export const loader =
(service: SettingsService) =>
(self: SettingsRepo) =>
(_previous: UserPreferences, set: (v: UserPreferences) => void): void => {
// self === settingsBranch
// Observes self.userId
const uid = self.userId;
if (!uid) { set({ ...DEFAULT_PREFERENCES }); return; }
service.load(uid).then(set);
};
export const update =
(service: SettingsService) =>
(self: SettingsRepo) =>
(fields: Partial<UserPreferences>): void => {
const uid = self.userId;
if (!uid) return;
const prev = self.data;
self.data = { ...self.data, ...fields };
service.update(uid, fields).catch(() => {
self.data = prev;
});
};
// feature/settings/actions.ts
// Please switch to typescript for the example.
All the advice I gave the AI on how to use tilia for state management are in the llms.txt documentation.
API Reference
✨ Carving ✨
tilia
Transform an object or array into a reactive object. Use this when you want a “quick and dirty” reactive object and you are not designing a feature.
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.log2("Alice is now", `${Int.toString(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(() => {
Array.forEach(updates, (update) => {
app->process(update)
})
})
// ✨ Notifications happen here
})
Functional Reactive Programming
✨ Rainbow architect, tilia has 8 more functions for you! ✨
Before introducing each one, let us show you an overview.
| Function | Use-case | Tree param | Previous value | Setter | Return value |
|---|---|---|---|---|---|
computed | Computed value from external sources | ❌ No | ❌ No | ❌ No | ✅ Yes |
carve | Cross-property computation | ✅ Yes | ❌ No | ❌ No | ✅ Yes |
source | External/async updates | ❌ No | ✅ Yes | ✅ Yes | ❌ No |
store | State machine/init logic | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
readonly | Avoid tracking on (large) readonly data | ||||
changing | Outbound write tracking for connectors | ❌ No | ❌ No | ❌ No | ✅ Yes ({changes, mute}) |
And some syntactic sugar:
| Function | Use-case | Implementation |
|---|---|---|
signal |
Create a mutable value and setter |
|
derived |
Creates a computed value based on other tilia values |
|
lift |
Unwrap a signal to insert it into a tilia object |
|
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.
Chaining computed values
computed values can depend on other computed values:
const store = tilia({
items: [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 },
],
discount: 0.1, // 10% discount
subtotal: computed(() =>
store.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
),
discountAmount: computed(() =>
store.subtotal * store.discount
),
total: computed(() =>
store.subtotal - store.discountAmount
),
});
console.log(store.total); // 225 (250 - 25)
store.discount = 0.2; // Change discount to 20%
console.log(store.total); // 200 (250 - 50)
open Tilia
let store = tilia({
items: [
{price: 100.0, quantity: 2},
{price: 50.0, quantity: 1},
],
discount: 0.1, // 10% discount
subtotal: computed(() =>
Array.reduce(store.items, 0.0, (sum, item) => sum +. item.price *. Float.fromInt(item.quantity))
),
discountAmount: computed(() =>
store.subtotal *. store.discount
),
total: computed(() =>
store.subtotal -. store.discountAmount
),
})
Js.log(store.total) // 225.0 (250.0 - 25.0)
store.discount = 0.2 // Change discount to 20%
Js.log(store.total) // 200.0 (250.0 - 50.0)
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),
})
Derived loader inside source
If you need to load data that depends on other parameters, you can combine source with derived:
const loader = (service: Service) =>
(self: { projectId: string }) =>
(previous: Project, set: (value: Project) => void) => {
// 1. Synchronous read (tracked)
const id = self.projectId;
// change the previous data to stale and show this while loading
set(stale(previous));
// 2. Delegate async work
service.loadProject(id).then((project) => {
// fully loaded: show
set(loaded(project));
});
};
const selectProject = (self: ProjectBranch) =>
(id: string) => (self.projectId = id);
const makeProject = (service: Service) =>
carve<ProjectBranch>(({ derived }) => ({
// state
projectId: "main",
// computed state
project: source(empty(), derived(loader(service))),
// actions
selectProject: derived(selectProject),
}));
let loader = service => self => (previous, set) => {
// 1. Synchronous read (tracked)
let id = self.projectId
// change the previous data to stale and show this while loading
set(stale(previous))
// 2. Delegate async work
let _ = service.loadProject(id)->Promise.thenResolve(project => {
// fully loaded: show
set(loaded(project))
})
}
let selectProject = self => id => self.projectId = id
let makeProject = service =>
carve(({derived}) => {
// state
projectId: "main",
// computed state
project: source(empty(), derived(loader(service))),
// actions
selectProject: derived(selectProject),
})
derived(loader)injects the carved object intoloader, so the source setup can use sibling fields likeself.projectId.- This lets the loader react to selection changes and refetch the right project.
previouskeeps the last value available while new values are loading, so the UI can keep showing stale data (for example greyed out) instead of blinking.
💡 Pro tip: Make sure that the source callback is not async. Tilia tracks reactive reads during synchronous execution only. Read dependencies synchronously, then delegate async work.
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),
});
const loggedOut = (set: Setter<Auth>): Auth => {
return {
t: "LoggedOut",
login: (user: User) => set(loggedIn(set, user)),
};
};
const 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> = {value: 'a}
let signal = (v: 'a) => {
let s = tilia({value: v})
(s, (v: 'a) => 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.
const 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
const 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,
})
changing
Track key-level writes on a tilia-proxied dict. Takes an accessor function
() => dict so the tracker can follow source swaps. Returns { changes, mute }:
changes is a capture function for watch that drains accumulated changes into
{ upsert, remove } on each cycle; mute runs a callback with tracking temporarily
suppressed. upsert contains objects captured at write time. remove contains keys
of deleted entries. Last write wins per key.
Each call to changing() creates an independent accumulator, so multiple
connectors can independently track the same object.
import { tilia, watch, changing } from "tilia";
const data = tilia<Record<string, Item>>({});
// Local DB: always sync
const { changes } = changing(() => data);
watch(changes, ({ upsert, remove }) => {
localDb.upsert(upsert);
localDb.remove(remove);
});
// Remote: sync only when online (guard)
const remote = changing(() => data, () => actor.online);
watch(remote.changes, ({ upsert, remove }) => {
service.upsert(upsert);
service.remove(remove);
});
// Feature code writes full updated objects (or deletes)
data[item.id] = item;
delete data[item.id]; // appears in remove
open Tilia
let data = tilia(Dict.make())
// Local DB: always sync
let {changes} = changing(() => data)
watch(changes, ({upsert, remove}) => {
localDB.upsert(upsert)
localDB.remove(remove)
})
// Remote: sync only when online (guard)
let remote = changing(() => data, ~guard=() => actor.online)
watch(remote.changes, ({upsert, remove}) => {
service.upsert(upsert)
service.remove(remove)
})
// Feature code writes full updated objects (or deletes)
Dict.set(data, item.id, item)
Dict.delete(data, item.id) // appears in remove
The guard parameter
When a guard function is provided and returns false, changes accumulate
silently without triggering the watcher. Only the guard is tracked. When the
guard flips to true, all accumulated changes drain and the effect fires with
the full batch. This uses tilia’s natural tracking — no special gating logic.
The mute function
Use mute to write inbound data (e.g., from a remote server) without
triggering outbound tracking. Writes inside mute are still reactive — the
UI updates — but they don’t appear in changes. This prevents feedback loops
in bidirectional sync scenarios.
const { changes, mute } = changing(() => data);
watch(changes, ({ upsert, remove }) => {
remote.upsert(upsert);
remote.remove(remove);
});
// Inbound: apply remote data without triggering outbound sync
mute(() => Object.assign(data, remoteData));
let {changes, mute} = changing(() => data)
watch(changes, ({upsert, remove}) => {
remote.upsert(upsert)
remote.remove(remove)
})
// Inbound: apply remote data without triggering outbound sync
mute(() => Dict.assign(data, remoteData))
The accessor pattern () => data lets changing follow data swaps. When
source replaces the underlying dict (e.g. loading a new page of tabular
data), the tracker re-registers on the new object automatically. Accumulated
changes from the old object are preserved and drain together.
💡 Pro tip: source handles inbound filtered queries (loading from
external into reactive state). changing + watch handles outbound data
sync (pushing reactive writes to external systems). Together they decouple
persistence from feature logic entirely.
React Integration
Installation
npm install @tilia/react
leaf (React Higher Order Component)
This is the favored way of making reactive components. Compared to using the
useTilia hook, the dependency tracking is exact which is not doable with hooks.
Wrap your component with leaf:
import { leaf } from "@tilia/react";
const App = leaf(() => {
// Now tilia tracks read operations and registers the exact
// dependencies of the current render.
if (alice.age >= 13) {
return <SocialMediaApp />;
} else {
return <NormalApp />;
}
});
open TiliaReact
@react.component
let make = leaf(() => {
// Now tilia tracks read operations and registers the exact
// dependencies of the current render.
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 and the leaf wrapper tracks dependencies.
useApp
This is just an advice on architecture and shows leaf usage with dependency injection for components (to make components testable).
Create an app context. Because tracking is fine-grained and the global state is mutated in place, this works seamlessly.
export type App = {
// ... compose app type from features
}
export const emptyApp = {
// default values. Can be used as basis for
// creating app mock objects during testing.
}
const AppContext = createContext<App>(emptyApp);
export const AppProvider = ({ app, children }: { app: App; children: React.ReactNode }) =>
<AppContext.Provider value={app}>{children}</AppContext.Provider>;
export const useApp = (): App => useContext(AppContext);
// App module
let app = {
// .. compose type
}
let empty: app = {
// default values. Can be used as basis for
// creating app mock objects during testing.
}
let context = React.createContext(empty);
let useApp = () => React.useContext(context)
module Provider = {
let make (~app) => React.Context.provider(app)
}
And then, components use the app like this:
import { leaf } from "@tilia/react"
import { useApp } from "../App"
export const TodoList = leaf(() => {
// ❌ AVOID reading all required elements at the top (it
// defeats the granularity of dependency tracking).
// const { todos: { list, count } } = useApp()
// ✅ do this for easy property renaming and readable values
// in the JSX: `count` can be anything `todos.count` is obvious.
// Plus it makes cleanup and refactoring easier.
const { todos } = useApp()
return <div>{todos.count}</div>
})
open TiliaReact
open App
@react.component
let make = leaf(() => {
// ❌ AVOID reading all required elements at the top (it
// defeats the granularity of dependency tracking).
// const { todos: { list, count } } = useApp()
// ✅ do this for easy property renaming and readable values
// in the JSX: `count` can be anything `todos.count` is obvious.
// Plus it makes cleanup and refactoring easier.
let {todos} = useApp()
<div>{todos.count->Int.toString->React.string}</div>
})
useTilia (React Hook)
Installation
npm install @tilia/react
Insert useTilia at the top of the React components that consume tilia values. This offers an easy way to make existing components reactive but it should be avoided because of the extra useEffect it requires to close dependency tracking at the end of the render phase. Use leaf instead.
import { useTilia } from "@tilia/react";
const 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 />
}
}
useComputed (React Hook)
useComputed lets you compute a value and only re-render if the result of the value changes (not the dependencies). This is useful for quick view only computations.
import { useTilia, useComputed } from "@tilia/react";
const TodoView = ({ todo }: { todo: Todo }) => {
useTilia();
const selected = useComputed(() => app.todos.selected.id === todo.id);
return <div className={selected ? "text-pink-200" : ""}>...</div>;
};
open TiliaReact
@react.component
let make = () => {
useTilia()
let selected = useComputed(() => app.todos.selected.id === todo.id)
<div className={selected ? "text-pink-200" : ""}>...</div>;
}
With this helper, the TodoView does not depend on app.todos.selected.id but on selected. This prevents the component from re-rendering on every change to the selected todo.
Deep Technical Reference
Internal Architecture
Proxy Handler Structure
Here is a simplified representation of the Proxy handler used by Tilia:
// Simplified for understanding
const createHandler = (context: TiliaContext) => ({
get(target: object, key: string | symbol, receiver: unknown) {
// 1. Ignore symbols and internal properties
if (typeof key === "symbol" || key.startsWith("_")) {
return Reflect.get(target, key, receiver);
}
// 2. Record dependency if an observer is active
if (context.currentObserver !== null) {
context.addDependency(context.currentObserver, target, key);
}
// 3. Retrieve the value
const value = Reflect.get(target, key, receiver);
// 4. If it's an object, wrap it recursively
if (isObject(value) && !isProxy(value)) {
return createProxy(value, context);
}
// 5. If it's a computed, execute it
if (isComputed(value)) {
return executeComputed(value, context);
}
return value;
},
set(target: object, key: string | symbol, value: unknown, receiver: unknown) {
const oldValue = Reflect.get(target, key, receiver);
// 1. Perform the modification
const result = Reflect.set(target, key, value, receiver);
// 2. Notify if the value changed
if (!Object.is(oldValue, value)) {
context.notify(target, key);
}
return result;
},
deleteProperty(target: object, key: string | symbol) {
const result = Reflect.deleteProperty(target, key);
// Notify of the deletion
if (result) {
context.notify(target, key);
}
return result;
},
ownKeys(target: object) {
// Track iteration over keys
if (context.currentObserver !== null) {
context.addDependency(context.currentObserver, target, KEYS_SYMBOL);
}
return Reflect.ownKeys(target);
},
});
Lifecycle of a computed
┌─────────────────────────────────────────────────────────────┐
│ INITIAL STATE │
│ computed created but not yet executed │
│ cache = EMPTY |
└─────────────────────────────────────────────────────────────┘
│
▼ (first read)
┌─────────────────────────────────────────────────────────────┐
│ EXECUTION │
│ 1. currentObserver = this computed │
│ 2. Execution of the function │
│ 3. Dependencies recorded during execution │
│ 4. cache = result |
│ 5. currentObserver = previous observer |
└─────────────────────────────────────────────────────────────┘
│
▼ (subsequent reads)
┌─────────────────────────────────────────────────────────────┐
│ CACHE HIT │
│ cache exists → return cache directly │
│ No recalculation │
└─────────────────────────────────────────────────────────────┘
│
▼ (dependency changes)
┌─────────────────────────────────────────────────────────────┐
│ INVALIDATION │
│ 1. SET detected on a dependency │
│ 2. if observed : value recomputed |
│ 3. value changed ? → notification propagated to observers |
│ 4. not observed : cache reset |
└─────────────────────────────────────────────────────────────┘
│
▼ (next read)
┌─────────────────────────────────────────────────────────────┐
│ RE-EXECUTION │
│ Same process as EXECUTION │
│ Potentially different new dependencies │
└─────────────────────────────────────────────────────────────┘
Forest Mode
Tilia supports “Forest Mode” where multiple separate tilia() objects can be observed together:
const alice = tilia({ name: "Alice", age: 10 });
const bob = tilia({ name: "Bob", age: 12 });
// A single observe that depends on TWO trees
observe(() => {
console.log(`${alice.name} is ${alice.age} years old`);
console.log(`${bob.name} is ${bob.age} years old`);
});
alice.age = 11; // ✨ Triggers the observe
bob.age = 13; // ✨ Also triggers the observe
This is possible thanks to the shared global context that maintains dependencies for all trees.
The “Glue Zone” and Security
The Orphan Computations Problem
Before v4, it was possible to create a computed outside of a Tilia object, which caused obscure errors:
// ❌ DANGER: computed created "in the void"
const trouble = computed(() => count.value * 2);
// Later, access outside a reactive context
const crash = trouble * 2; // 💥 Obscure error!
The “Glue Zone”
The “Glue Zone” is the dangerous area where a computation definition exists without being attached to an object. In v4, Tilia adds protections to avoid this problem.
// BEFORE (Glue Zone - dangerous)
const computed_def = computed(() => x.value * 2);
// 'computed_def' is a "ghost" - neither a value, nor attached to an object
// AFTER (insertion in an object - safe)
const obj = tilia({
double: computed(() => x.value * 2) // ✅ Created directly in the object
});
Safety Proxies
Since v4, computation definitions (computed, source, store) are wrapped in a Safety Proxy:
- In a reactive context (tilia/carve): the proxy unwraps transparently
- Outside: the proxy throws a descriptive error
const [count, setCount] = signal(0);
// ❌ Creating an orphan
const orphan = computed(() => count.value * 2);
// 🛡️ v4 Protection: Throws a clear error
const result = orphan * 2;
// Error: "Orphan computation detected. computed/source/store must be
// created directly inside a tilia or carve object."
Golden rule
NEVER assign the result of a
computed,source, orstoreto an intermediate variable.
ALWAYS define them directly in atilia()orcarve()object.
// ❌ Bad
const myComputed = computed(() => ...);
const obj = tilia({ value: myComputed });
// ✅ Good
const obj = tilia({
value: computed(() => ...)
});
Flush Strategy and Batching
Two behaviors depending on context
When Tilia notifies observers depends on where the modification occurs:
| Context | Behavior | Example |
|---|---|---|
| Outside observation | Immediate flush | Code in an event handler, setTimeout, etc. |
| Inside observation context | Deferred flush | In derived, observe, leaf, etc. |
Outside observation context: immediate flush
When you modify a value outside an observation context, each modification triggers an immediate notification:
const state = tilia({ a: 1, b: 2 });
observe(() => {
console.log(`a=${state.a}, b=${state.b}`);
});
// Output: "a=1, b=2"
// Outside observation context (e.g., in an event handler)
state.a = 10;
// ⚡ IMMEDIATE notification!
// Output: "a=10, b=2"
state.b = 20;
// ⚡ IMMEDIATE notification!
// Output: "a=10, b=20"
The problem of inconsistent transient states
This behavior can cause problems when multiple properties must change together coherently:
const rect = tilia({
width: 100,
height: 50,
ratio: computed(() => rect.width / rect.height),
});
observe(() => {
console.log(`Dimensions: ${rect.width}x${rect.height}, ratio: ${rect.ratio}`);
});
// Output: "Dimensions: 100x50, ratio: 2"
// Want to go to 200x100 (same ratio)
rect.width = 200;
// ⚠️ Inconsistent transient state!
// Output: "Dimensions: 200x50, ratio: 4" ← incorrect ratio!
rect.height = 100;
// Output: "Dimensions: 200x100, ratio: 2" ← correct now
The observer saw an intermediate state where the ratio was 4, which was never the intention.
batch(): the solution for grouped modifications
batch() allows grouping multiple modifications and notifying only once at the end:
import { batch } from "tilia";
// ✅ With batch: a single coherent notification
batch(() => {
rect.width = 200;
rect.height = 100;
// No notification during the batch
});
// ✨ Single notification here
// Output: "Dimensions: 200x100, ratio: 2"
Typical use cases for batch():
- Event handlers that modify multiple properties
- WebSocket/SSE callbacks with multiple updates
- Initialization of multiple values
Inside observation context: automatic deferred flush
Inside a computed, observe, watch callback, or a component with leaf/useTilia, notifications are automatically deferred. No need to use batch():
const state = tilia({
items: [],
processedCount: 0,
});
observe(() => {
// Inside an observation context, modifications are batched
for (const item of incomingItems) {
state.items.push(item);
state.processedCount++;
// No notification here, even if observers are watching these values
}
// ✨ Notifications at the end of the callback
});
Recursive mutations in observe
If you modify a value observed by the same callback in observe, it will be scheduled for re-execution after the current execution ends:
observe(() => {
console.log("Value:", state.value);
if (state.value < 5) {
state.value++; // Schedules a new execution
}
});
// Output:
// "Value: 0"
// "Value: 1"
// "Value: 2"
// "Value: 3"
// "Value: 4"
// "Value: 5"
⚠️ Attention: This feature is powerful but can create infinite loops if misused.
Mutations in computed: infinite loop risk
The main danger of mutations in a computed is the risk of an infinite loop: if the computed reads the value it modifies, it invalidates itself and loops.
const state = tilia({
items: [],
// ❌ DANGER: the computed reads AND modifies 'items'
count: computed(() => {
// Read 'items'
const len = state.items.length;
// Write to 'items' → invalidates the computed!
state.items.push(len);
// → Recalculate → Read → Write → ∞
return len;
}),
});
// Accessing state.count causes an infinite loop!
let state = tilia({
items: [],
// ❌ DANGER: the computed reads AND modifies 'items'
count: computed(() => {
// Read 'items'
const len = state.items->Array.length;
// Write to 'items' → invalidates the computed!
state.items->Array.push(len);
// → Recalculate → Read → Write → ∞
return len;
}),
});
// Accessing state.count causes an infinite loop!
The problem: The computed observes items, then modifies it, which invalidates it and causes a new calculation, which observes again, modifies again, etc.
Solution: use watch to separate observation and mutation
watch clearly separates:
- The observation phase (first callback): tracked, defines dependencies
- The mutation phase (second callback): without tracking, no loop risk
const state = tilia({
count: 0,
history: [] as number[],
});
// ✅ GOOD: watch separates observation and mutation
watch(
// Observation: tracked
() => state.count,
(count) => {
// Mutation: no tracking here
state.history.push(count);
}
);
state.count = 1; // history becomes [1]
state.count = 2; // history becomes [1, 2]
let state = tilia({
count: 0,
history: [],
});
// ✅ GOOD: watch separates observation and mutation
watch(
// Observation: tracked
() => state.count,
(count) => {
// Mutation: no tracking here
state.history.push(count);
}
);
state.count = 1; // history becomes [1]
state.count = 2; // history becomes [1, 2]
With watch, the mutation in the second callback is not tracked, so it cannot create a loop even if it reads and modifies the same values.
Garbage Collection
What JavaScript’s native GC manages
JavaScript’s native garbage collector manages very well the release of tracked objects that are no longer used in memory. If a tilia({...}) object is no longer referenced anywhere, JavaScript automatically releases it, along with all its internal dependencies.
You don’t need to do anything for this: it’s JavaScript’s standard behavior.
What Tilia’s GC manages
For each observed property, Tilia maintains a list of watchers. When a watcher is “cleared” (for example, when a React component unmounts), it is removed from the list, but the list itself (even empty) remains attached to the property.
These empty lists represent very little data, but Tilia cleans them up periodically:
import { make } from "tilia";
// GC threshold configuration
const ctx = make({
gc: 100, // Triggers cleanup after 100 watchers cleared
});
// The default threshold is 50
When cleanup triggers
- A watcher is “cleared” (component unmounted, etc.)
- The
clearedWatcherscounter increments - If
clearedWatchers >= gc, cleanup of the watcher list clearedWatchersresets to 0
Configuration based on application
// Application with many dynamic components (lists, tabs, modals)
const ctx = make({ gc: 200 });
// More stable application with few mount/unmounts
const ctx = make({ gc: 30 });
In practice, the default threshold (50) suits most applications.
Error Handling
Errors in computed and observe
When an exception is thrown in a computed or observe callback, Tilia adopts an error reporting strategy to avoid blocking the application:
- The exception is caught immediately
- The error is logged in
console.errorwith a cleaned stack trace - The faulty observer is cleaned up (cleared) to avoid blocking the system
- The error is re-thrown at the end of the next flush
const state = tilia({
value: 0,
computed: computed(() => {
if (state.value === 42) {
throw new Error("The universal answer is forbidden!");
}
return state.value * 2;
}),
});
observe(() => {
console.log("Computed:", state.computed);
});
// Everything works
state.value = 10; // Log: "Computed: 20"
// Triggers an error
state.value = 42;
// 1. Error is logged immediately in console.error
// 2. Observer is cleaned up
// 3. Error is re-thrown at the end of the flush
Why defer the error?
This behavior allows:
- Not blocking other observers: If one observer crashes, others continue to function
- Keeping the application stable: The reactive system is not locked by an error
- Logging immediately: The error appears in the console as soon as it occurs
- Propagating the error: The exception still bubbles up to be handled by the application
Cleaned stack trace
To facilitate debugging, Tilia cleans the stack trace by removing internal library lines. You see directly where the error occurred in your code:
Exception thrown in computed or observe
at myComputed (src/domain/feature.ts:42:15)
at handleClick (src/components/Button.tsx:18:5)
Best practices
// ✅ Handle error cases in computed
const state = tilia({
data: computed(() => {
try {
return riskyOperation();
} catch (e) {
console.error("Operation failed:", e);
return { error: true, message: e.message };
}
}),
});
// ✅ Use default values
const state = tilia({
user: computed(() => fetchedUser ?? { name: "Anonymous" }),
});
Main Features
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:
- Ubiquitous Language in Code: Tilia’s API encourages you to model your application state using the same terms and structures that exist in your business domain. With minimal boilerplate and no imposed framework-specific terminology, your codebase can closely mirror the language and logic of your domain, making it easier for both developers and domain experts to understand and collaborate12.
- Bounded Contexts and Modularity:
Tilia enables you to compose state into clear, isolated modules (using
carve, for example), which naturally map to DDD’s concept of bounded contexts. Each feature or subdomain can be managed independently, reducing complexity and making it easier to evolve or refactor parts of your system as business requirements change13. - Rich Domain Models: By allowing you to define computed properties, derived state, and domain-specific actions directly within your state objects, Tilia helps you build rich domain models. This keeps business logic close to the data it operates on, improving maintainability and clarity12.
- Continuous Evolution: Tilia’s reactive model and compositional API make it easy to refactor and extend your domain models as your understanding of the business evolves. This aligns with DDD’s emphasis on evolutionary design and ongoing collaboration with domain experts3.
- Improved Communication and Onboarding: Because Tilia encourages code that reads like your business language, new team members and stakeholders can more quickly understand the system. This reduces onboarding time and the risk of miscommunication between technical and non-technical team members2.
- Testability and Isolation: Tilia’s modular state and clear separation between state, actions, and derived values enable you to test domain logic in isolation, a key DDD best practice4.
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.
Complete Guides
Comprehensive guides with detailed explanations and examples:
- Guide complet en français - Guide complet pour comprendre et utiliser Tilia
Changelog
2026-04-15 5.3.0 (beta)
- Renamed
changedtochangingwith simplified API:{ changes, mute }wherechangesreturns{ upsert, remove }.
2026-04-15 5.2.0 (beta)
- Improved
changingAPI to support data loaded viasource.
2026-04-15 5.1.0
- Add
changingfor dictionary key change tracking.
2025-12-18 5.0.0
- Update to ReScript v12.
2025-12-18 4.0.0
- Changed
@tilia/reactdependency to track MAJOR.MINOR version of tilia. - Add apps to test different project setup.
- Improve error reporting for "Orphan Computation Error". See https://tiliajs.com/errors.
- Remove explicit 'exports' from package.json to support any suffix in ReScript setup.
- Add previous value to
sourceas first parameter. - Move parameter order in
source, starting with initial value. - Move
sourceandstoreinto the context and allow computed in source callback (to be used with derived).
2025-09-09 3.0.0
- Rename
unwrapforlift, change syntax forsignalto expose setter. - Protect tilia from exceptions in computed: the exception is caught, logged to
console.errorand re-thrown at the end of the next flush. - Add
leafto @tilia/react: a higher order component to close the observing phase at the exact end of the render. - Simplify
useComputedin @tilia/react to return the value directly.
2025-08-08 2.2.0
- Add
unwrapto ease inserting a signal into a tilia object.
2025-08-08 2.1.1
- Fix
sourcetype: ignore return value for easier async support.
2025-08-03 2.1.0
- Add
derivedto compute a signal from other tilia values. - Add
watchto separate the capture phase and the effect phase of observe.
2025-07-24 2.0.1
- Fix package.json configuration in @tilia/react publish script.
2025-07-21 2.0.0
- Add tests and examples with Gherkin for todo app.
- Moved core to npm "tilia" package.
- Changed
makesignature to build tilia context (provides the full API running in a separate context). - Enable forest mode to observe across separated objects.
- Add
computedto compute values in branches (moved intotiliacontext). - Moved
observeintotiliacontext. observewill be called for its own mutations (this is to allow state machines).- Removed re-exports in @tilia/react.
- Removed
compute(replaced bycomputed). - Removed
trackas this cannot scale to multiple instances and computed. - Renamed internal
_connectto_observe. - Reworked API to ensure strong typing and avoid runtime errors.
- Add
source,readonlyandsignalfor FRP style programming. - Add
carveto support derivation (build domain features from objects). - Improved flush strategy to trigger immediately but not in an observing function.
2025-05-05 1.6.0
- Add
computemethod to cache values on read.
2025-01-17 1.4.0
- Add
trackmethod to observe branches. - Add
flushstrategy for tracking notification.
2025-01-02 1.3.2
- Fix extension in built artifacts.
2024-12-31 1.3.0
- Expose internals with
_meta. - Rewrite tracking to fix memory leaks when
_readyandclearare never called.
2024-12-27 1.2.4
- Add support for ready after clear.
2024-12-24 1.2.3
- Rewrite tracking to fix notify and clear before ready.
2024-12-18 1.2.2
- Fix readonly tracking: should not proxy.
2024-12-18 1.2.1
- Fix bug to not track prototype methods.
2024-12-18 1.2.0
- Improve ownKeys watching, notify on key deletion.
2024-12-18 1.1.1
- Fix build issue (rescript was still required).
2024-12-17 1.1.0
- Add support to share tracking between branches.
2024-12-13 1.0.0
- Alpha release.
carve
This is where Tilia truly shines. It lets you build a domain-driven, self-contained feature that is easy to test and reuse.
Define your logic as pure functions:
Build your featuree as a single, reactive object.
Example
💡 Pro tip: Carving is a powerful way to build domain-driven, self-contained features. Extracting logic into pure functions (like
listandtoggle) makes testing and reuse easy.Recursive derivation (state machines)
For recursive derivation (such as state machines), use
sourceinsidecarve:This allows you to create dynamic or self-referential state that reacts to changes in other parts of the tree.
For conditional loaders derived from the carved object itself, see Derived loader inside source.
Difference from
computedcomputedfor pure derived values that do not depend on the entire object.derived(viacarve) when you need access to the full reactive object for cross-property logic or methods.Look at todos.ts for an example of using
carveto build the todos feature.