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
# With React
npm install @tilia/react
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.
Non-goal Tilia is not a framework.
Fundamental Concepts
The Observer Pattern
The classic pattern
The Observer pattern (or Publish-Subscribe) is a behavioral design pattern where an object, called Subject, maintains a list of Observers and automatically notifies them of any state change.
βββββββββββββββββββ βββββββββββββββββββ
β Subject βββnotifyβββΆβ Observer 1 β
β (source of β βββββββββββββββββββ€
β truth) βββnotifyβββΆβ Observer 2 β
β β βββββββββββββββββββ€
β βββnotifyβββΆβ Observer 3 β
βββββββββββββββββββ βββββββββββββββββββ
In the classic implementation, the observer must explicitly subscribe and unsubscribe:
// Classic Observer pattern
subject.subscribe(observer); // Manual subscription
// ... later
subject.unsubscribe(observer); // Manual unsubscription (source of bugs!)
// Classic Observer pattern
subject->subscribe(observer) // Manual subscription
// ... later
subject->unsubscribe(observer) // Manual unsubscription (source of bugs!)
Tiliaβs approach: automatic tracking
Tilia revolutionizes this pattern by automatically detecting which properties are observed. No need to manually subscribe or unsubscribe!
import { tilia, observe } from "tilia";
const alice = tilia({
name: "Alice",
age: 10,
city: "Paris",
});
observe(() => {
// Tilia detects that only 'name' and 'age' are read
console.log(`${alice.name} is ${alice.age} years old`);
});
alice.age = 11; // β¨ Triggers the callback (age is observed)
alice.city = "Lyon"; // π΄ Does NOT trigger the callback (city is not observed)
open Tilia
let alice = tilia({
name: "Alice",
age: 10,
city: "Paris",
})
observe(() => {
// Tilia detects that only 'name' and 'age' are read
Js.log2(`${alice.name} is`, `${Int.toString(alice.age)} years old`)
})
alice.age = 11 // β¨ Triggers the callback (age is observed)
alice.city = "Lyon" // π΄ Does NOT trigger the callback (city is not observed)
Dynamic tracking: only the last execution matters
A crucial point to understand: Tilia doesnβt look statically at which properties could be read in your function. It only records properties that were actually read during the last execution of the callback.
This means that if your callback contains an if condition, dependencies change based on the executed branch:
import { tilia, observe } from "tilia";
const state = tilia({
showDetails: false,
name: "Alice",
email: "alice@example.com",
phone: "01 23 45 67 89",
});
observe(() => {
// 'name' is ALWAYS read
console.log("Name:", state.name);
if (state.showDetails) {
// 'email' and 'phone' are tracked only if show details is true
console.log("Email:", state.email);
console.log("Phone:", state.phone);
}
});
// Initial state: showDetails = false
// Current dependencies: { name, showDetails }
state.email = "new@email.com";
// π΄ No notification! 'email' was not read during the last execution
state.showDetails = true;
// β¨ Notification! showDetails is observed
// The callback re-executes, this time reading email and phone
// New dependencies: { name, showDetails, email, phone }
state.email = "another@email.com";
// β¨ Notification! Now email IS observed
open Tilia
let state = tilia({
showDetails: false,
name: "Alice",
email: "alice@example.com",
phone: "01 23 45 67 89",
})
observe(() => {
// 'name' is ALWAYS read
Js.log2("Name:", state.name)
if state.showDetails {
// 'email' and 'phone' are read ONLY if showDetails === true
Js.log2("Email:", state.email)
Js.log2("Phone:", state.phone)
}
})
// Initial state: showDetails = false
// Current dependencies: { name, showDetails }
state.email = "new@email.com"
// π΄ No notification! 'email' was not read during the last execution
state.showDetails = true
// β¨ Notification! showDetails is observed
// The callback re-executes, this time reading email and phone
// New dependencies: { name, showDetails, email, phone }
state.email = "another@email.com"
// β¨ Notification! Now email IS observed
This dynamic behavior is extremely powerful: your callbacks are never notified for values they donβt actually use, which automatically optimizes performance.
How Tilia Builds the Dependency Graph
JavaScriptβs Proxy API
Tilia uses JavaScriptβs Proxy API to intercept property access on objects. A Proxy is a transparent wrapper that allows defining custom behaviors for fundamental operations (read, write, etc.).
// Simplified Proxy principle
const handler = {
get(target, property) {
console.log(`Reading ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`Writing ${property} = ${value}`);
target[property] = value;
return true;
}
};
const obj = { name: "Alice" };
const proxy = new Proxy(obj, handler);
proxy.name; // Log: "Reading name"
proxy.name = "Bob"; // Log: "Writing name = Bob"
The tracking mechanism
When you call tilia({...}), the object is wrapped in a Proxy with two essential βtrapsβ (interceptions):
1. The GET trap (read)
When a property is read during the execution of an observation callback, Tilia records this property as a dependency:
// Simplified internal state of Tilia
let currentObserver = null; // The observer currently executing
const dependencies = new Map(); // Map: observer -> Set of dependencies
const handler = {
get(target, key) {
if (currentObserver !== null) {
// π Recording the dependency
// "This observer depends on this property"
addDependency(currentObserver, target, key);
}
return target[key];
},
// ...
};
2. The SET trap (write)
When a property is modified, Tilia finds all observers that depend on it and notifies them:
const handler = {
// ...
set(target, key, value) {
const oldValue = target[key];
target[key] = value;
if (oldValue !== value) {
// π’ Notification of observers
// "This property changed, notify all those who depend on it"
notifyObservers(target, key);
}
return true;
}
};
Dynamic graph
A crucial point: the dependency graph is dynamic. It is rebuilt on each callback execution, which allows handling conditions:
const state = tilia({
showDetails: false,
name: "Alice",
email: "alice@example.com",
});
observe(() => {
console.log("Name:", state.name);
if (state.showDetails) {
// 'email' is observed ONLY if showDetails is true
console.log("Email:", state.email);
}
});
// Current dependencies: {name, showDetails}
state.email = "new@email.com"; // π΄ No notification (email not observed)
state.showDetails = true; // β¨ Notification + re-execution
// Now dependencies include: {name, showDetails, email}
state.email = "another@email.com"; // β¨ Notification (email is now observed)
Carve and Domain-Driven Design
The accidental complexity problem
In many state management libraries, business code ends up polluted with technical concepts. Developers must constantly juggle between domain logic and reactive mechanisms:
// β Code polluted with FRP concepts
const personStore = createStore({
firstName: signal("Alice"),
lastName: signal("Dupont"),
fullName: computed(() =>
personStore.firstName.get() + " " + personStore.lastName.get()
),
});
// To read a value, you must "think FRP"
const name = personStore.firstName.get(); // .get() ? .value ? () ?
personStore.lastName.set("Martin"); // .set() ? .update() ?
This code exposes the reactive plumbing instead of the business domain. A business expert reading this code would see .get(), .set(), signal() instead of simply seeing βa person with a nameβ.
Tiliaβs approach: domain first
With Tilia, you manipulate your business objects like ordinary JavaScript objects. Reactivity is invisible:
// β
Domain-oriented code
const person = tilia({
firstName: "Alice",
lastName: "Dupont",
fullName: computed(() => `${person.firstName} ${person.lastName}`),
});
// Natural reading, like a normal object
console.log(person.firstName); // "Alice"
console.log(person.fullName); // "Alice Dupont"
// Natural modification
person.lastName = "Martin";
console.log(person.fullName); // "Alice Martin" β¨ Automatic
// β
Domain-oriented code
open Tilia
let person = tilia({
firstName: "Alice",
lastName: "Dupont",
fullName: computed(() => `${person.firstName} ${person.lastName}`),
})
// Natural reading, like a normal object
Js.log(person.firstName) // "Alice"
Js.log(person.fullName) // "Alice Dupont"
// Natural modification
person.lastName = "Martin"
Js.log(person.fullName) // "Alice Martin" β¨ Automatic
Here, person.firstName reads exactly like in any JavaScript code. No .get(), no .value, no function to call. Itβs simply an object with properties.
Ubiquitous Language
Domain-Driven Design (DDD) emphasizes the importance of a shared vocabulary between developers and business experts. This vocabulary, called βubiquitous languageβ, should appear directly in the code.
Tilia facilitates this approach by allowing you to write code that resembles the domain:
// The code speaks the same language as the business
const cart = tilia({
items: [],
promoCode: null,
subtotal: computed(() =>
cart.items.reduce((sum, a) => sum + a.price * a.quantity, 0)
),
discount: computed(() =>
cart.promoCode?.percentage
? cart.subtotal * cart.promoCode.percentage / 100
: 0
),
total: computed(() => cart.subtotal - cart.discount),
});
// A business expert can read and understand this code
if (cart.total > 100) {
applyFreeShipping();
}
No trace of FRP in this code. We talk about cart, items, total - exactly the same terms an e-commerce manager would use.
Bounded Contexts and modularity
In DDD, a Bounded Context is a conceptual boundary where a particular model is defined and applicable. Tilia and carve naturally allow creating these boundaries:
// "Catalog" context
const catalog = carve<CatalogContext>(({ derived }) => ({
products: [],
categories: [],
search: derived((self) => (term: string) => { /* ... */ }),
filterByCategory: derived((self) => (cat: string) => { /* ... */ }),
}));
// "Cart" context - different model, same product
const cart = carve<CartContext>(({ derived }) => ({
lines: [], // Not "products" - different vocabulary in this context
add: derived((self) => (product: Product, quantity: number) => { /* ... */ }),
total: derived((self) => /* ... */),
}));
Each context uses its own vocabulary, its own rules, while remaining reactive.
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.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 7 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 |
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),
})
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),
});
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,
})
β¨ 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.
const feature = carve(({ derived }) => { ... fields })
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.
const 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.
const toggle = ({ data, repo }: Todos) => (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"
const 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
- Use
computedfor pure derived values that do not depend on the entire object. - Use
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 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";
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 />
}
}
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(() => {
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";
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, valid = false β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (first read)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β EXECUTION β
β 1. currentObserver = this computed β
β 2. Execution of the function β
β 3. Dependencies recorded during execution β
β 4. cache = result, valid = true β
β 5. currentObserver = null β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (subsequent reads)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CACHE HIT β
β valid = true β return cache directly β
β No recalculation β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (dependency changes)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β INVALIDATION β
β 1. SET detected on a dependency β
β 2. valid = false β
β 3. Notification propagated to observers β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ (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 (v4)
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 (v4)
In 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 computed, observe, watch, leaf, useTilia |
Outside observation context: immediate flush
When you modify a value outside an observation context, each modification triggers immediately a 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: [] as number[],
// β DANGER: the computed reads AND modifies 'items'
count: computed(() => {
const len = state.items.length; // Read 'items'
state.items.push(len); // Write to 'items' β invalidates the computed!
return len; // β Recalculate β Read β Write β β
}),
});
// 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]
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
2025-07-13 2.0.0 (beta version)
- Moved core to "tilia" npm package.
- Changed
makesignature to build tilia context. - Enable forest mode to observe across separated objects.
- Add
computedto compute values in branches. - Moved
observeinto tilia context. - Added
signal, andsourcefor FRP style programming. - Added
carvefor derivation. - Simplify
useTiliasignature. - Add garbage collection to improve performance.
See the full changelog in the README.
What is Functional Reactive Programming (FRP)?
Functional Reactive Programming (FRP) is a programming paradigm that combines two powerful approaches:
The problem FRP solves
In a traditional application, when data changes, you must manually update all parts of the application that depend on it. This leads to complex, fragile, and hard-to-maintain code:
With FRP, dependencies are declared once and updates propagate automatically:
The two reactivity models
Tilia intelligently combines two complementary reactivity models:
PUSH Reactivity (observe, watch)
The push model means that changes βpushβ notifications to observers. When a value changes, all callbacks that depend on it are automatically re-executed.
Use cases: Side effects (logs, DOM updates, API calls), state synchronization.
PULL Reactivity (computed)
The pull model means that values are computed lazily, only when they are read. The value is then cached until one of its dependencies changes.
Use cases: Derived values, data transformations, filters, aggregations.
Why combine both?
Tilia allows you to choose the appropriate model based on context, optimizing performance while keeping code expressive.