Documentation
Complete guide to using Tilia for simple and fast state management in TypeScript and ReScript applications.
Installation
Use the beta version (not yet released API mostly stable):
npm install tilia
Goals and Non-goals
The goal of Tilia is to provide a minimal and fast state management solution that supports domain-oriented development (such as Clean or Diagonal Architecture). Tilia is designed so that your code looks and feels like domain logic, rather than being cluttered with library-specific details.
Non-goal Tilia is not a framework.
API Reference
tilia
Transform an object or array into a reactive tilia value.
import { tilia } from "tilia";
const alice = tilia({
name: "Alice",
birthday: dayjs("2015-05-24"),
age: 10,
});
open Tilia
let alice = tilia({
name: "Alice",
birthday: dayjs("2015-05-24"),
age: 10,
})
Alice can now be observed. Who knows what she will be doing?
observe
Use observe to monitor changes and react automatically. When an observed value changes, your callback function is triggered (push reactivity).
During the callback’s execution, Tilia tracks which properties are accessed in the connected objects and arrays. The callback always runs at least once when observe is first set up.
import { observe } from "tilia";
observe(() => {
console.log("Alice is now", alice.age, "years old !!");
});
alice.age = 11; // ✨ This triggers the observe callback
open Tilia
observe(() => {
Js.log3("Alice is now", alice.age, "years old !!")
})
alice.age = 11; // ✨ This triggers the observe callback
📖 Important Note: If you mutate an observed tilia value during the observe call, the callback will be re-run as soon as it ends.
Now every time alice’s age changes, the callback will be called.
watch
Use watch similarly to observe
, but with a clear separation between the
capture phase and the effect phase. The capture function observes values,
and the effect function is called when the captured values change.
import { watch } from "tilia";
watch(
() => exercise.result,
(r) => {
if (r === "Pass") {
// The effect runs only when `exercise.result` changes, not when
// `alice.score` changes because the latter is not captured.
alice.score = alice.score + 1;
} else if (r === "Fail") {
alice.score = alice.score - 1;
}
}
);
// ✨ This triggers the effect
exercise.result = "Pass";
// 😴 This does not trigger the effect
alice.score = alice.score + 10;
open Tilia
watch(
() => exercise.result,
r => switch r {
// The effect runs only when `exercise.result` changes, not when
// `alice.score` changes because the latter is not captured.
| Pass => alice.score = alice.score + 1
| Fail => alice.score = alice.score - 1
| Pending => ()
}
)
// ✨ This triggers the effect
exercise.result = "Pass";
// 😴 This does not trigger the effect
alice.score = alice.score + 10;
📖 Note: If you mutate an observed tilia value in the capture or effect function, the callback will not be re-run and this change will be ignored.
Now every time alice finishes an exercise, her score updates.
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 | Setter | Return value |
---|---|---|---|---|
computed | Computed value from external sources | ❌ No | ❌ No | ✅ Yes |
carve | Cross-property computation | ✅ Yes | ❌ No | ✅ Yes |
source | External/async updates | ❌ No | ✅ Yes | ❌ No |
store | State machine/init logic | ❌ No | ✅ Yes | ✅ Yes |
readonly | Avoid tracking on (large) readonly data |
And some syntactic sugar:
Function | Use-case | Implementation |
---|---|---|
signal * | Holds a mutable value | v => tilia({ value: v }) |
derived | Creates a computed value based on other tilia values | fn => signal(computed(fn)) |
unwrap * | Unwrap a signal to insert it into a tilia object | s => computed(() => s.value) |
* These methods are changing in version 3.0.0 (currently in beta).
computed
Return a computed value to be inserted in a Tilia object.
The value is computed when the key is read (pull reactivity) and is destroyed (invalidated) when any observed value changes.
import { computed } from "tilia";
const globals = tilia({ now: dayjs() });
setInterval(() => (globals.now = dayjs()), 1000 * 60);
const alice = tilia({
name: "Alice",
birthday: dayjs("2015-05-24"),
// The value 'age' is always up-to-date
age: computed(() => globals.now.diff(alice.birthday, "year")),
});
open Tilia
open Day
let globals = tilia({ now: now() })
setInterval(() => globals.now = now(), 1000 \* 60)
let alice = tilia({
name: "Alice",
birthday: dayjs("2015-05-24"),
age: 0,
})
alice.age = computed(() => globals.now->diff(alice.birthday, "year"))
Nice, the age updates automatically, Alice can grow older :-)
💡 Pro tip: The computed can be created anywhere but only becomes active inside a Tilia object or array.
Once a value is computed, it behaves exactly like a regular value until it is expired due to a change in the dependencies. This means that there is nearly zero overhead for computed values acting as getters.
source
Return a reactive source to be inserted into a Tilia object.
A source is similar to a computed, but it receives a setter function and does not return a value. The setup callback is called on first value read and whenever any observed value changes. The initial value is used before the first set call.
const app = tilia({
// Async data (re-)loader (setup will re-run when alice's age changes.
social: source(
(set) => {
if (alice.age > 13) {
fetchData(set);
} else {
set({ t: "NotAvailable" });
}
},
{ t: "Loading" }
),
// Subscription to async event (online status)
online: source(subscribeOnline, false),
});
let app = tilia({
// Async data (re-)loader (setup will re-run when alice's age changes.
social: source(
set => {
// "social" setup will re-run when alice's age changes
if (alice.age > 13) {
fetchData(set)
} else {
set(NotAvailable)
}
},
Loading
),
// Subscription to async event (online status)
online: source(subscribeOnline, false),
})
The see different uses of source
, store
and computed
, you can have a look
at the todo app.
store
Return a computed value, created with a setter that will be inserted in a Tilia object.
import { computed } from "tilia";
const app = tilia({
auth: store(loggedOut),
});
function loggedOut(set: Setter<Auth>): Auth {
return {
t: "LoggedOut",
login: (user: User) => set(loggedIn(set, user)),
};
}
function loggedIn(set: Setter<Auth>, user: User): Auth {
return {
t: "LoggedIn",
user: User,
logout: () => set(loggedOut(set)),
};
}
open Tilia
let loggedOut = set => LoggedOut({
login: user => set(loggedIn(set, user)),
})
let loggedIn = (set, user) => LoggedIn({
user: User,
logout: () => set(loggedOut(set)),
})
let app = tilia({
auth: store(loggedOut),
})
💡 Pro tip: store
is a very powerful pattern that makes it easy to initialize a feature in a specific state (for testing for example).
readonly
A tiny helper to mark a field as readonly (and thus not track changes to its fields):
import { type Readonly, readonly } from "tilia";
const app = tilia({
form: readonly(bigStaticData),
});
// Original `bigStaticData` without tracking
const data = app.form.data;
// 🚨 'set' on proxy: trap returned falsish for property 'data'
app.form.data = { other: "data" };
open Tilia
let app = tilia({
form: readonly(bigStaticData),
})
// Original `bigStaticData` without tracking
let data = app.form.data
// 🚨 'set' on proxy: trap returned falsish for property 'data'
app.form.data = { other: "data" }
signal
A signal represents a single, changing value of any type.
This is a tiny wrapper around tilia
to expose a single, changing value.
In version 3.0.0 (currently in beta), the signal function returns a signal and setter.
type Signal<T> = { value: T };
function signal<T>(value: T): Signal<T> {
return tilia({ value });
}
// Usage
const s = signal(0);
s.value = 1;
console.log(s.value);
type signal<'a> = {mutable value: 'a}
let signal = value => tilia({value: value})
// Usage
let s = signal(0)
s.value = 1
Js.log(s.value)
🌱 Small tip: Using tilia
with your own field names is usually prefered to signal
as it reflects your domain:
// ✅ Domain-driven
const app = tilia({
authenticated: false,
now: store(runningTime),
});
if (app.authenticated) {
}
// 🌧️ Less readable
const authenticated_ = signal(false);
const now_ = signal(store(runningTime));
if (authenticated_.value) {
}
// ✅ Domain-driven
let app = tilia({
authenticated: false,
now: store(runningTime),
})
if app.authenticated {
}
// 🌧️ Less readable
let authenticated_ = signal(false)
let now_ = signal(store(runningTime))
if (authenticated_.value) {
}
derived
Create a signal representing a computed value. This is similar to the derived
argument of carve
, but outside of an object.
function derived<T>(fn: () => T): Signal<T> {
return signal(computed(fn));
}
// Usage
const s = signal(0);
const double = derived(() => s.value * 2);
console.log(double.value);
let derived = fn => signal(computed(fn))
// Usage
let s = signal(0)
let double = derived(() => s.value * 2)
Js.log(double.value)
unwrap
Create a computed
value that reflects the current value of a signal to be
inserted into a Tilia object. Use signal and unwrap to create private state
and expose values as read-only.
Renamed to lift in version 3.0.0 (currently in beta).
function unwrap<T>(s: Signal<T>): T {
return computed(() => s.value);
}
// Usage
type Todo = {
readonly title: string;
setTitle: (title: string) => void;
};
const s = signal("");
const todo = tilia({
title: unwrap(s),
setTitle: (title) => {
// todo.title will reflect the new title
s.value = title;
},
});
let unwrap = s => computed(() => s.value)
// Usage
type todo = {
title: string,
setTitle: title => unit,
}
let s = signal("")
let todo = tilia({
title: unwrap(s),
setTitle: title => {
// todo.title will reflect the new title
s.value = title
},
})
✨ Carving ✨
carve
This is where Tilia truly shines. It lets you build a domain-driven, self-contained feature that is easy to test and reuse.
open Tilia
let feature = carve(({ derived }) => { ... fields })
The derived
function in the carve argument is like a computed
but with the
object itself as first parameter.
Example
import { carve, source } from "tilia";
// A pure function for sorting todos, easy to test in isolation.
function list(todos: Todos) {
const compare = todos.sort === "by date"
? (a, b) => a.createdAt.localeCompare(b.createdAt)
: (a, b) => a.title.localeCompare(b.title);
return [...todos.data].sort(compare);
}
// A pure function for toggling a todo, also easily testable.
function toggle({ data, repo }: Todos) {
return (id: string) => {
const todo = data.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
repo.save(todo)
} else {
throw new Error(`Todo ${id} not found`);
}
};
}
// Injecting the dependency "repo"
function makeTodos(repo: Repo) {
// ✨ Carve the todos feature ✨
return carve({ derived }) => ({
sort: "by date",
list: derived(list),
data: source(repo.fetchTodos, []),
toggle: derived(toggle),
repo,
});
}
open Tilia
// A pure function for sorting todos, easy to test in isolation.
let list = todos =>
todos->Array.toSorted(switch todos.sort {
| ByDate => (a, b) => String.compare(a.createdAt, b.createdAt)
| ByTitle => (a, b) => String.compare(a.title, b.title)
})
// A pure function for toggling a todo, also easily testable.
let toggle = ({ data, repo }: Todos.t) =>
switch data->Array.find(t => t.id === id) {
| None => raise(Not_found)
| Some(todo) =>
todo.completed = !todo.completed
repo.save(todo)
}
// Injecting the dependency "repo"
let makeTodos = repo =>
// ✨ Carve the todos feature ✨
carve(({ derived }) => {
sort: ByDate,
list: derived(list),
data: source(repo.fetchTodos, []),
toggle: derived(toggle),
})
💡 Pro tip: Carving is a powerful way to build domain-driven, self-contained features. Extracting logic into pure functions (like list
and toggle
) makes testing and reuse easy.
Recursive derivation (state machines)
For recursive derivation (such as state machines), use source
:
derived((tree) => source(machine, initialValue));
derived(tree => source(machine, initialValue))
This allows you to create dynamic or self-referential state that reacts to changes in other parts of the tree.
Difference from computed
- Use
computed
for 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";
function App() {
useTilia();
if (alice.age >= 13) {
return <SocialMediaApp />;
} else {
return <NormalApp />;
}
}
open TiliaReact
@react.component
let make = () => {
useTilia()
if (alice.age >= 13) {
<SocialMedia />
} else {
<NormalApp />
}
}
The App component will now re-render when alice.age
changes because “age” was read from “alice” during the last render.
useComputed (React Hook)
useComputed
lets you compute a value and only re-render if the result changes.
import { useTilia, useComputed } from "@tilia/react";
function TodoView({ todo }: { todo: Todo }) {
useTilia();
const selected = useComputed(() => app.todos.selected.id === todo.id);
return <div className={selected.value ? "text-pink-200" : ""}>...</div>;
}
open TiliaReact
@react.component
let make = () => {
useTilia()
let selected = useComputed(() => app.todos.selected.id === todo.id)
<div className={selected.value ? "text-pink-200" : ""}>...</div>;
}
With this helper, the TodoView does not depend on app.todos.selected.id
but on selected.value
. This prevents the component from re-rendering on every change to the selected todo.
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.
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
, andsource
for FRP style programming. - Added
carve
for derivation. - Simplify
useTilia
signature. - Add garbage collection to improve performance.
See the full changelog in the README.
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 incomputed
,source
,store
,observe
orwatch
where notifications are already blocked.