tilia

Guide·v6.x·Guided tour

The Guide

A reading path through tilia, in order: the mental model first, then tracking, computed values, observers, and React. Read it top to bottom once. When you need to look something up later, use the API reference instead — it is flat, complete, and made for that.

01

Drawn before built

This chapter is for the person deciding whether tilia belongs in their stack. There is no code in it.

tilia is a state management library for TypeScript and ReScript applications. Its goal is deliberately narrow: provide a minimal, fast reactive layer that supports domain-oriented development — Clean Architecture, Diagonal Architecture, or any style where the business domain leads. tilia is not a framework, and that is a design decision, not a limitation.

Conviviality, methodically supported

Underneath the API sits a conviction: building software together — with colleagues, with a cousin, with an AI assistant — should stay convivial for as long as the software lives.

convivial /con·vi·vi·al/ adj., Latin — of the shared table (convivium): con-, together, vivere, to live.

Ivan Illich made the word a standard that tools can be held to:

Convivial tools are those which give each person who uses them the greatest opportunity to enrich the environment with the fruits of his or her vision.

— Ivan Illich, Tools for Conviviality, 1973

Conviviality does not die by accident; it dies in specific, preventable ways. Each one is answered by a rule of the architecture:

  • Fear of change. A feature is a bounded, carved thing: its state, its logic and its actions live together, and touching one feature ripples nowhere else.
  • The translation tax. The domain's words survive into the running code. No reader converts "the queue" back into business meaning, because the code never left the business's language.
  • Glue work. The wiring between state, derivation and action is the library's job. People write only things worth reading.
  • The world in the way. Features ask for the world — current date, storage, network — and receive it injected. Every test runs on a world you control.
  • The onboarding wall. A new mind — cousin, colleague, or AI — reads the shape and knows where everything lives.
  • Logic you can't hold. Every rule of the domain is a pure function you can read, test, and hand over whole.

The rest of this guide is these six rules, shown rather than argued.

The shape comes first

Most state libraries ask your team to describe how data changes: actions, reducers, dispatch, selectors. The domain ends up encoded in library vocabulary, and every reader must translate back to the business to understand what the code does.

tilia inverts this. You draw the shape of your state as plain objects — a card, a deck, a review session — and declare how each value relates to the others. The library's job is to keep the running program true to that declaration: when something changes, everything that depends on it follows, automatically and precisely. Your code looks and behaves like business logic, because it is business logic.

How applications are structured

The recommended structure separates an application into a few categories:

  • features — the business logic, one self-contained object per feature, holding its state, its derived values and its actions.
  • repo — the persistence layer, one self-contained object per data type that is saved.
  • services — technical connectors to the outside world (current date, storage, translations, audio), injected into the feature or repo that needs them.
  • views — the face. Views read features and render; they hold no business logic.

Behavior itself is decided before it is built, in scenarios written in the domain's own words — executable specifications that the running program is checked against for the life of the project. This working method is called épure, after the full-scale drawing a builder traces before cutting, and tilia is its state layer: the way of making sure the drawing and the program never drift apart.

What it costs

Very little, by design. tilia has zero dependencies and weighs around 10 KB. Reactivity is highly granular — a view re-renders only when a value it actually read has changed — computed values are cached until a dependency changes, and tracking follows objects even when they are moved or copied. It combines push reactivity (react when something changes) and pull reactivity (compute only when someone asks), so work happens exactly when it is needed and not before. The same API serves TypeScript and ReScript.

How this guide works

The guide builds one small, believable thing: a spaced-repetition scheduler — flashcards that come back for review at growing intervals. And it builds it the way real software gets built now: by more than one mind.

Story

Alice has a shoebox of Spanish flashcards. Some she knows cold, some she keeps forgetting. The box is about to become software — but not by Alice's hand. Her cousin Adèle will design it, and Adèle will build it with Claudine, an AI who has never seen the project, the library, or the shoebox.

That cast is the tension of the whole guide. Software survives by being handed between minds — and every handoff is a place where meaning can be lost. Each chapter starts with something Alice wants, in her words; ends with that want alive and verified; and introduces the one idea that made the handoff safe. A reader moving front to back builds one coherent mental model; the final chapter steps back and points onward. If you decide for your team, this chapter and that one may be all you need. The chapters between are for whoever will build — human or machine, the mental model is the same.

02

The kitchen table

There is no tilia in this chapter either. There is the first artifact of the project — and the only person who can approve it writes no code.

Story

Alice calls her cousin on a Sunday. "You build things. My shoebox is overflowing and I keep forgetting perro." Adèle laughs, and says yes. By the evening she has an empty directory and two small files in it.

Two small files

The first is CONTRIBUTING.md, copied from the épure project. It is addressed to everyone who will ever build here — humans and AI assistants alike — and it opens with three promises:

  1. The scenarios are the design. Every want becomes a scenario — Given, When, Then, in the domain's own words — before it becomes code. The .feature files are the project's design contracts and its decision ledger.
  2. Everything else is scaffolding. Sketches, prose designs, prompts: useful while a want is being turned into scenarios, gone once it has been.
  3. Green is the handshake. A feature is done when its scenarios pass, and it stays done because they keep passing.

The second file is AGENTS.md — the file an AI assistant's tooling reads at the start of every session. It says one thing: read CONTRIBUTING.md.

Adèle also sketches the app — screens, arrows, a box labeled the queue. The sketch will not survive the week, and that is the second promise at work: it exists to become scenarios, not to be maintained.

What Alice knows

At the kitchen table, Adèle asks Alice how the shoebox actually works. Alice does not describe software; she describes a habit.

"When I get a card right, I put it further back in the box — it should leave me alone for a while, then come back. Twice as long each time, more or less. And every morning, some cards are just… due. Nobody moves them. It's morning, so they're due."

Adèle types while Alice talks. What she types is not a program:

Contract
Feature: Spaced repetition

  Scenario: a passed card waits longer
    Given a card "gato" with interval 3 days
    When Alice passes "gato"
    Then "gato" waits 6 days

  Scenario: cards come due on their own
    Given a card "gato" reviewed 3 days ago with interval 3 days
    When midnight comes
    Then "gato" is due

She turns the laptop around. Alice reads it out loud, all the way through, and says: "that's exactly it."

The first artifact of the project is one the domain owner can read, correct, and sign. It is written in her words — passes, waits, due, midnight — and those words will appear in the code, in the tests, and in every conversation about the app, unchanged. This file is an épure in the old craft sense — the drawing made at full scale before anything is cut, the one every built piece is laid back onto to check the fit.

Enter Claudine

Adèle opens a session with Claudine. There is no tour: Claudine's tooling has already read AGENTS.md and followed it to CONTRIBUTING.md; she reads the promises, then the .feature file. She has never seen this project — that is her permanent condition; every session, she is the newest member of the team. CONTRIBUTING.md is written for exactly this reader: it tells her where truth lives (the scenarios), what to ignore (everything else), and to read the installed reference of any library before using it, so that an unknown is a question to ask, never an API to guess.

Claudine runs the suite — the scenarios execute directly, under épure's test runner. Two scenarios, two failures.

Story

Two red lines on Adèle's screen: the project's entire to-do list, written in Alice's words, checked by a machine.

Red is not failure here: red is the distance between the drawing and the build, and it can only shrink. The next chapter closes the first gap: for a card to pass and wait longer, there must first be a card, and it must be alive.

03

A living object

Claudine's first task is the first scenario: a passed card waits longer. Everything in tilia starts with two moves — make an object reactive, and react to it — and both fit in her first commit.

An object, unchanged

tilia transforms a plain object or array into a reactive one. Nothing about its shape changes — you read and write properties exactly as before. What changes is invisible: every read and write can now be tracked, including on nested objects and arrays.

Example
import { tilia } from "tilia";

const card = tilia({
  front: "gato",
  back: "cat",
  interval: 3, // days before the card comes back
});

const pass = (card: Card) => {
  card.interval = card.interval * 2;
};
open Tilia

let card = tilia({
  front: "gato",
  back: "cat",
  interval: 3, // days before the card comes back
})

let pass = card => card.interval = card.interval * 2

pass is an ordinary function, mutating an ordinary-looking object, named with Alice's word. There is no store to register, no wrapper type to unwrap, no action to dispatch. The card is still a card: the domain model is the state layer, and nothing else in the code needs to know that tilia exists — except the glue, which comes next.

Reacting: observe

observe registers a callback that re-runs whenever a value it read has changed. This is push reactivity: changes push the callback to run.

Example
import { observe } from "tilia";

observe(() => {
  console.log(`"${card.front}" comes back in ${card.interval} day(s)`);
});

pass(card); // ✨ "gato" comes back in 6 day(s)
open Tilia

observe(() => {
  Js.log(`"${card.front}" comes back in ${card.interval->Int.toString} day(s)`)
})

pass(card) // ✨ "gato" comes back in 6 day(s)

This is the mechanism all of tilia rests on. While the callback runs, tilia records which properties are read on which reactive objects — those exact properties, no more, become its dependencies. You never wrote a subscription, an event name, or an unsubscribe: you declared what the reaction needs by simply using it, and tilia drew the wiring from that. Writing a value equal to the current one notifies nobody; when a reaction should end, observe returns a function that stops it.

A forest, not a tree

Reactive objects do not need to share a root. Separate tilia objects live in one shared context, and a single observer can depend on several of them — Alice's profile here, the settings there, no "which store does this belong to?" ever asked. Tracking even follows objects that are moved or copied between reactive parents: assign a card into another tilia object and both paths see the same living value.

Pro tip

Use tilia when you want a quick reactive object. Designing a feature — state, derived values and actions as one self-contained object — is what carve is for, two chapters from here.

Green

Adèle wires the scenario's words to these two functions — a small file whose workings chapter 6 opens up — and runs the suite.

Story

One line turns green: a passed card waits longer. Alice tries it that evening — taps Pass, and gato answers: six days. The first sentence she said at the kitchen table is now something the machine checks forever.

One scenario remains red, and it is the more interesting one: cards come due on their own. Due-ness is not something anyone sets — it is a value that should follow other values. That is the next chapter.

Reference: tilia, observe

04

Values that follow

Cards come due on their own. Alice's second scenario makes an odd demand: nobody moves the cards, yet at midnight they are due. observe reacts to change by doing something. What this scenario needs is a value that simply is something — always correct, derived from other values, never manually refreshed. That is computed.

Pull, not push

A computed value is the mirror image of an observer. Where observe uses push — changes push the callback to run — computed uses pull: the value is calculated when the key is read, cached, and invalidated when a dependency changes. Until someone reads it again, no work happens.

Due-ness depends on the last review, the interval, and today's date — so let today's date be a reactive value too. A signal gives that standalone value a home; its setter will matter when midnight arrives:

Example
import { tilia, computed, signal } from "tilia";

const [today] = signal("2026-07-15");

const card = tilia({
  front: "gato",
  interval: 3,
  lastReview: "2026-07-12",
  dueDate: computed(() => addDays(card.lastReview, card.interval)),
  due: computed(() => card.dueDate <= today.value),
});
open Tilia

let (today, _) = signal("2026-07-15")

let card = tilia({
  front: "gato",
  interval: 3,
  lastReview: "2026-07-12",
  dueDate: "",
  due: false,
})
card.dueDate = computed(() => addDays(card.lastReview, card.interval))
card.due = computed(() => card.dueDate <= today.value)

Consider what did not have to be written: no refreshDueDate() after every review, no midnight job that walks the cards, no risk of a stale dueDate because some code path forgot. The relationship was declared once; tilia keeps it true. And computed values chain — change interval and dueDate expires, which expires due, and anything watching due reacts. The graph assembles itself.

Reading a computed that has not expired is a cache hit, nearly free. This is why you can model everything derivable this way, instead of rationing derived values like expensive selectors.

Claudine's first mistake

Claudine's first draft did something reasonable-looking:

Example
// ❌ a definition, stranded in a variable
const due = computed(() => card.dueDate <= today.value);
if (due) { ... }
// 💥 Error: orphan computation detected
// ❌ a definition, stranded in a variable
let due = computed(() => card.dueDate <= today.value)
if due { ... }
// 💥 Error: orphan computation detected

computed returns a definition, not a value — it only comes to life once it is inserted into a tilia object. Used anywhere else, it fails immediately, loudly, at the line that broke the rule. Claudine reads the error, moves the computed into the card, and the moment passes — no silent wrong value, no bug surfacing three files away, no human needed to be watching. The library holds the rule, so no collaborator — however new — can drift far from it.

Pro tip

The golden rule: never assign a computed to an intermediate variable — define it directly inside a tilia or carve object. Chapter 11 tells the rest of the safety story.

Green, at midnight

Adèle runs the suite: cards come due on their own turns green.

Story

The scenario said "When midnight comes" — and midnight came, on a Tuesday afternoon, in eleven milliseconds. The app owns its idea of today.

How midnight comes on command is chapter 6. But first: one card knowing its schedule is not an app. Alice has a boxful, and a box with an order to it, an action to review, a place to keep everything — that is not a value. It is a feature.

Reference: computed, signal

05

Carving a feature

Alice's whole shoebox arrives, and with it the first scenarios that talk about the deck as a thing: which cards line up, in what order, what happens when one is reviewed. Adèle writes them with a table — Alice's actual cards, straight from the box:

Contract
Feature: The deck

  Background:
    Given a deck of cards
      | front | back | interval | reviewed   |
      | gato  | cat  | 1        | yesterday  |
      | perro | dog  | 3        | 4 days ago |
      | luna  | moon | 8        | yesterday  |

  Scenario: due cards line up, oldest first
    Then the queue is "perro, gato"

  Scenario: a reviewed card leaves the queue
    When Alice passes "perro"
    Then the queue is "gato"

The deck has state (the cards), derived state (the queue) and actions (review). They belong together and should be testable as one unit. Building that unit is carve's job.

Logic as pure functions

Claudine starts by writing the logic with no library in sight. A queue is a function of a deck. A review is a function of a deck, applied to a card:

Example
const queue = (deck: Deck) =>
  deck.cards
    .filter((c) => c.dueDate <= deck.today)
    .sort((a, b) => (a.dueDate < b.dueDate ? -1 : 1));

const review = (deck: Deck) => (id: string, result: Result) => {
  const card = deck.cards.find((c) => c.id === id);
  if (!card) return;
  card.interval = result === "Pass" ? card.interval * 2 : 1;
  card.lastReview = deck.today;
  deck.repo.save(card);
};
let queue = deck =>
  deck.cards
  ->Array.filter(c => c.dueDate <= deck.today)
  ->Array.toSorted((a, b) => String.compare(a.dueDate, b.dueDate))

let review = deck => (id, result) =>
  switch deck.cards->Array.find(c => c.id === id) {
  | None => ()
  | Some(card) =>
    card.interval = result === Pass ? card.interval * 2 : 1
    card.lastReview = deck.today
    deck.repo.save(card)
  }

These are ordinary functions: data in, data out. You can test queue with a plain object and an assertion — there is no reactive system to mock.

Assembling the feature

carve builds the reactive object and hands the functions the object itself, through derived:

Example
import { carve, lift, type Signal } from "tilia";

const makeDeck = (repo: Repo, today: Signal<string>) =>
  carve<Deck>(({ derived }) => ({
    // state
    cards: [],
    // computed state
    queue: derived(queue),
    // actions
    review: derived(review),
    // injected dependencies
    repo,
    today: lift(today),
  }));
open Tilia

let makeDeck = (repo, today) =>
  carve(({derived}) => {
    // state
    cards: [],
    // computed state
    queue: derived(queue),
    // actions
    review: derived(review),
    // injected dependencies
    repo,
    today: today->lift,
  })

derived(queue) means: call queue with the carved object, track what it reads, and keep the result current. When review doubles a card's interval, its dueDate moves and the queue follows at the next read. Nobody wired these together; the shape of the object is the wiring.

Story

Adèle reads Claudine's diff before running anything. queue. review. due. She is not translating: the code is the kitchen-table conversation, typed. She signs it, and two more lines turn green.

Three words fill a carved object:

  • computed — a value that stands alone, closing over whatever it needs, as card.due did.
  • derived — logic that needs the carved object itself: cross-property values like queue, actions like review that read and write siblings.
  • lift — inserts the current value of an existing signal.

That leaves repo and today, which are not built here at all — they arrive as arguments. The deck knows that cards are saved and that the date advances, never how. The next chapter is about those two arguments, and about how midnight came on a Tuesday afternoon.

Reference: carve, computed, lift

06

A date you can set

This chapter adds no scenario. It answers a question two chapters old: in chapter 4, the suite said "When midnight comes" — and midnight came, on command, in milliseconds. Nobody waited. How?

Opening the steps file

Between the scenarios and the code sits one small file Adèle wrote: the steps file. It is where the drawing's words meet the build — each Given, When and Then bound to a few lines of code. Here is its heart:

Example
import { Given, type Context } from "@epure/vitest";
import { signal } from "tilia";
import { makeDeck } from "../src/features/deck";
import { memoryRepo } from "./memoryRepo";

Given("a deck of cards", ({ When, Then }: Context, table: string[][]) => {
  const [today, setToday] = signal("2026-07-15");
  const deck = makeDeck(memoryRepo(toCards(table)), today);

  When("midnight comes", () => {
    setToday(addDays(today.value, 1));
  });

  When("Alice passes {string}", (front: string) => {
    deck.review(byFront(deck, front).id, "Pass");
  });

  Then("the queue is {string}", (expected: string) => {
    expect(deck.queue.map((c) => c.front).join(", ")).toBe(expected);
  });
});
open VitestBdd

given("a deck of cards", ({step}, table) => {
  let (today, setToday) = Tilia.signal("2026-07-15")
  let deck = Deck.make(MemoryRepo.make(toCards(table)), today)

  step("midnight comes", () => setToday(addDays(today.value, 1)))

  step("Alice passes {string}", front =>
    deck.review(byFront(deck, front).id, Pass)
  )

  step("the queue is {string}", expected =>
    expect(deck.queue->Array.map(c => c.front)->Array.join(", "))->toBe(expected)
  )
})

Read the first two lines inside Given. The steps file builds a world — a repo that lives in memory, a today signal fixed to a date — and hands both to the feature. Then "midnight comes" is one call to setToday: the date advances, due values follow (chapter 4 at work), and the queue reorders. Midnight is not simulated. For this deck, whose only sense of the current date is the signal it was handed, midnight genuinely happens.

Asking, not reaching

None of this would be possible if the deck had reached for the world — imported a storage module, read the system date. It never does. Features and repos ask for the world and receive it injected, as arguments:

Example
// the app, for Alice
const today = systemDate();
const deck = makeDeck(indexedDbRepo(), today);

// the same app, for a scenario
const [testToday] = signal("2026-07-15");
const testDeck = makeDeck(memoryRepo(cards), testToday);
// the app, for Alice
let today = SystemDate.make()
let deck = Deck.make(IndexedDbRepo.make(), today)

// the same app, for a scenario
let (testToday, _) = Tilia.signal("2026-07-15")
let testDeck = Deck.make(MemoryRepo.make(cards), testToday)

Same feature, same code, two worlds. The connectors — real storage, the system date, the network someday — live in services/, and they are the only place the outside world is touched. This is dependency injection — not a testing technique bolted on afterward, but the structural decision that makes a feature a complete, self-contained unit of meaning.

What it buys

  • The suite is fast — no database to start, no midnight to wait for. Alice's scenarios run in milliseconds, so they run constantly, so green stays current.
  • Green is trustworthy — every scenario runs on a world under total control, so a red line means the behavior changed, never that the network hiccuped.
  • Claudine builds alone — no credentials, no test server, no "works on my machine." The world she needs is constructed in the first line of the steps file.
  • The feature enumerates its needsmakeDeck(repo, today) is a complete list of what the deck touches. Nothing hides in an import.
Story

Adèle explains the trick to Alice, who thinks about it and asks the right question: "So you can make it any day you want?" — "Any day we want." — "Then test a Sunday. The café cards. I only ever get those wrong on Sundays."

A date you can set, a repo in memory: the world so far is one the deck politely asked for. But some of the world is not asked for — it arrives: saved cards loading from storage, a session moving through its states. Letting that world in, without losing any of the calm, is next.

Reference: signal

07

Letting the world in

Everything so far was synchronous and self-contained. Real applications load data, wait for storage, and move through states over time. Two new wants make it concrete:

Contract
Scenario: cards survive a restart
  Given Alice passed "gato" yesterday
  When the app restarts
  Then "gato" still waits 2 days

Scenario: a session offers only legal moves
  Given an idle session
  Then Alice can start it, and nothing else

tilia's answer is two primitives that put external and asynchronous values inside the reactive object, where the rest of the system treats them like any other value: source and store.

source: a value fed from outside

A source is like a computed, but instead of returning a value, its setup function receives a setter and calls it — now, later, or repeatedly. The setup runs on first read, and again whenever a reactive value it read synchronously has changed. That re-run rule turns a plain loader into a reactive loader:

Example
import { carve, lift, source, type Signal } from "tilia";

const loader =
  (deck: Deck) =>
  (_previous: Card[], set: (cards: Card[]) => void) => {
    const id = deck.deckId; // synchronous read: tracked
    deck.repo.fetchCards(id).then(set); // async work: delegated
  };

const makeDeck = (repo: Repo, today: Signal<string>) =>
  carve<Deck>(({ derived }) => ({
    deckId: "spanish",
    cards: source([], derived(loader)),
    queue: derived(queue),
    review: derived(review),
    selectDeck: derived((deck) => (id: string) => (deck.deckId = id)),
    repo,
    today: lift(today),
  }));
let loader = deck => (_previous, set) => {
  let id = deck.deckId // synchronous read: tracked
  deck.repo.fetchCards(id)->Promise.thenResolve(set)->ignore // async: delegated
}

let makeDeck = (repo, today) =>
  carve(({derived}) => {
    deckId: "spanish",
    cards: source([], derived(loader)),
    queue: derived(queue),
    review: derived(review),
    selectDeck: derived(deck => id => deck.deckId = id),
    repo,
    today: today->lift,
  })

The cards now come from the injected repo — so cards survive a restart goes green against the in-memory repo, and works identically against real storage. And because the loader read deck.deckId, it re-runs when the deck changes:

Story

Alice taps French on a whim. The deck notices its own deckId change, fetches the right cards, and the queue follows. She taps back to Spanish before dinner. Nobody wrote a refresh.

Without this, selecting a deck means: call the fetch, guard against races, store the result, invalidate the queue, notify the views. Here, selectDeck writes one field and everything downstream follows.

Pro tip

Keep the source callback synchronous: tilia tracks reads during synchronous execution only. Read your dependencies first, then delegate the async work, as loader does.

Pro tip

This loader reads from injected local storage. When the cards one day live on a server — with caching, refresh, offline — that whole lifecycle is @tilia/query's job. The loader then gives way to a loadable list, read with cards.array(...), while the surrounding feature keeps the same vocabulary.

store: a value that manages itself

Where source feeds a value from outside, store hands the setter to the value itself: the setup receives set and returns the initial value, and whatever it builds can capture set and decide its own future. This is the natural shape for state machines — like the review session, which Alice's scenario says must offer only legal moves:

Example
import { tilia, store, type Setter } from "tilia";

const idle = (set: Setter<Session>): Session => ({
  t: "Idle",
  start: () => set(reviewing(set)),
});

const reviewing = (set: Setter<Session>): Session => ({
  t: "Reviewing",
  finish: () => set(finished(set)),
});

const finished = (set: Setter<Session>): Session => ({
  t: "Finished",
  restart: () => set(idle(set)),
});

const app = tilia({
  session: store(idle),
});
open Tilia

let rec idle = set => Idle({start: () => set(reviewing(set))})
and reviewing = set => Reviewing({finish: () => set(finished(set))})
and finished = set => Finished({restart: () => set(idle(set))})

let app = tilia({
  session: store(idle),
})

Illegal transitions are not rejected at runtime — they are unrepresentable. app.session in the Idle state has a start and nothing else, which is what the scenario asserts.

Pro tip

store also makes tests pleasant to start anywhere: hand it reviewing instead of idle and a scenario begins mid-session, no clicking through.

The deck now lives, loads, switches, and runs sessions — three more green lines. Four small words remain in tilia's vocabulary, and their whole job is naming intentions.

Reference: source, store, lift

08

A small vocabulary

Alice wants a streak — days in a row with every due card reviewed. It is one number, and it raises a question Claudine asks before writing anything: who may change it? Anyone should read it; only the review logic should bump it. The question is about ownership.

The four words are signal, standalone derived, lift, and readonly. Three are one-liners over what you already know; they add no power, only names — there is still one mechanism underneath.

signal, lift: reading is public, writing is owned

You have already seen signal and lift work together to carry today's date into the deck. A signal is a mutable record with one value field, returned together with a setter; lift inserts its current value into another object as a computed. The steps owned setToday, while the deck received a public today that it could only read. Alice's streak uses the same split:

Example
import { signal, lift, tilia } from "tilia";

const [streak, setStreak] = signal(0);
// setStreak stays inside the review logic

const stats = tilia({
  streak: lift(streak), // ✅ readable by anyone, writable by no one else
});
open Tilia

let (streak, setStreak) = signal(0)
// setStreak stays inside the review logic

let stats = tilia({
  streak: streak->lift, // ✅ readable by anyone, writable by no one else
})

The domain exposes stats.streak in domain language; neither the signal nor its setter leaves the module. A whole class of "who owns this value" bugs cannot be written — the ownership is not a convention in a comment, it is the shape of the code.

Story

Seven days in a row. The number on Alice's screen and the number the session logic increments are the same value — there is no copy to drift.

Standalone derived rounds out the pair: it creates a signal from other reactive values — a value that follows, living on its own before it has a home in a larger object.

readonly: opting out

Tracking costs a little, and immutable data does not need it. readonly wraps a value so tilia leaves it alone — reads return the original data untouched, writes throw. The deck catalogue Alice browses — hundreds of decks, updated never — rides along inside the reactive app without paying for reactivity it will not use:

Example
import { readonly } from "tilia";

const app = tilia({
  catalogue: readonly(allDecks), // large, static: not tracked
});
open Tilia

let app = tilia({
  catalogue: readonly(allDecks), // large, static: not tracked
})

That is the whole vocabulary. What remains: when, exactly, do reactions run? The answer involves midnight again — this time for real.

Reference: signal, derived, lift, readonly

09

While Alice sleeps

Every night at three, the app imports the day's new cards — Adèle subscribed Alice to a shared Spanish deck. Dozens of writes land at once, and they force the question: when, exactly, does a reaction run? tilia has one clear rule, one tool for the exception, and one function for separating cause from effect.

The flush rule

When you modify a value outside any observation context — an event handler, a timeout — every write notifies immediately. When you modify a value inside one — a computed, observe or watch callback, a rendering component — notifications are deferred until the callback ends.

The immediate half keeps simple code simple: write a value, watch the screen change. But it means multi-field updates expose transient states. A review touches two fields:

Example
card.interval = 6;
// ⚠️ an observer running now sees the new interval with the
// OLD lastReview: a dueDate that was never true
card.lastReview = deck.today;
card.interval = 6
// ⚠️ an observer running now sees the new interval with the
// OLD lastReview: a dueDate that was never true
card.lastReview = deck.today

batch: atomic by declaration

batch groups writes and notifies once, at the end:

Example
import { batch } from "tilia";

batch(() => {
  card.interval = 6;
  card.lastReview = deck.today;
});
// ✨ one coherent notification here
open Tilia

batch(() => {
  card.interval = 6
  card.lastReview = deck.today
})
// ✨ one coherent notification here

The review now moves from one true state to the next. Around the nightly import, the same boundary turns forty-one pushes into one coherent change:

Story

Three in the morning. Forty-one new cards slide into the deck — one notification, one repaint that nobody sees. When Alice wakes, the queue is simply longer, and el desayuno is waiting.

Pro tip

batch is not needed inside computed, source, store, observe or watch — there, notifications are already deferred. Reach for it in event handlers, network callbacks, and initialization code.

watch: cause, then effect

Alice's score should follow her results — a value change causing a mutation. That must not be a computed (a computed that writes what it reads invalidates itself, forever), and plain observe would track too much. watch splits the two phases: a capture function, tracked, defines what is being watched; an effect function runs untracked when the captured value changes.

Example
import { watch } from "tilia";

watch(
  () => session.result,
  (r) => {
    if (r === "Pass") alice.score = alice.score + 1;
    else if (r === "Fail") alice.score = alice.score - 1;
  }
);
open Tilia

watch(
  () => session.result,
  r =>
    switch r {
    | Pass => alice.score = alice.score + 1
    | Fail => alice.score = alice.score - 1
    | Pending => ()
    },
)

The rule that matters: a watch never re-triggers itself from its own writes, in either phase — while its effects still notify everyone else, deferred, as one batch. Cause and effect, cleanly separated; the scenario the score follows results goes green with these six lines.

One sharper tool for completeness: inside observe, mutating a value the same callback reads schedules one more run, repeating until a run changes nothing. That is how an observe can drive a state machine to a fixed point — and how it can loop forever if no fixed point exists. Deliberate, powerful, sharp; make sure every self-feeding observe converges.

The whole scheduling model fits in one sentence: writes notify immediately unless a reactive callback or a batch is running, and every callback sees a coherent world. With the model complete, it is time to give the scheduler a face.

Reference: batch, watch, observe

10

tilia in React

This chapter adds no scenario. The deck, the session, today's date, the streak: all of it was built and verified without a pixel. Now the views arrive, and the suite stays green, untouched — business in features, no logic in views, exactly as CONTRIBUTING.md promised.

Views are observers. That one idea is the entire React integration: a component reads reactive values while rendering, and it should re-render exactly when one of those values changes. The @tilia/react package (installed separately) offers three tools, in a deliberate order of preference.

leaf: the favored way

leaf wraps a component so that tilia tracks the reads of the render itself:

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

const CardView = leaf(() => {
  const { deck } = useApp();
  const card = deck.queue[0];
  return card ? <div>{card.front}</div> : <AllDone />;
});
open TiliaReact

@react.component
let make = leaf(() => {
  let {deck} = useApp()
  switch deck.queue[0] {
  | Some(card) => <div> {card.front->React.string} </div>
  | None => <AllDone />
  }
})

Because tracking happens during the render, the dependencies are exact: this component re-renders when deck.queue changes and not otherwise. No dependency array, no memoized selector, no memo wrapper. The component reads the domain; the subscription is the reading. And its vocabulary is still the domain's — deck, queue, front: a view Alice could read over Adèle's shoulder.

useApp is an architectural suggestion, not an API: provide the app object through an ordinary React context and let components pull the feature they need. Because tracking is fine-grained, one context for the whole app works seamlessly — and a test provides a mock app the same way. The world stays injected, even here.

Story

The scheduler gets its face on a Saturday. Alice answers, the queue advances, and only the card on screen repaints — the streak counter, the deck list, the settings panel never notice. It feels less like a program updating and more like a page turning.

Pro tip

Take features from the context, not deep values: const { deck } = useApp(), then read deck.queue in the JSX where it is used. Destructuring everything at the top defeats the granularity of the tracking.

useTilia: the easy retrofit

useTilia is a hook called at the top of a component that makes the reads below it reactive:

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

const CardView = () => {
  useTilia();
  const card = app.deck.queue[0];
  return card ? <div>{card.front}</div> : <AllDone />;
};
open TiliaReact

@react.component
let make = () => {
  useTilia()
  switch app.deck.queue[0] {
  | Some(card) => <div> {card.front->React.string} </div>
  | None => <AllDone />
  }
}

It is the fastest way to make an existing component reactive, and that is its role: a retrofit for gradual adoption. Its tracking is slightly coarser than leaf's — a hook cannot see the exact end of its own render — so prefer leaf for new code; the API reference has the precise mechanics.

useComputed: re-render on the answer

Sometimes a component depends on a conclusion, not the values behind it. Each row of Alice's queue wants to know one thing — am I the current card? useComputed re-asks the question cheaply and re-renders only when its own answer flips:

Example
const current = useComputed(() => app.deck.queue[0]?.id === card.id);
let current = useComputed(() =>
  app.deck.queue[0]->Option.map(c => c.id)->Option.getOr("") === card.id
)

Two rows repaint per advance, no matter how long the list.

Every piece of the scheduler — deck, session, today's date, streak, views — is now an object or a function that could be read aloud at the kitchen table. One question remains: what happens when someone gets it wrong?

Reference: leaf, use-tilia, use-computed

11

Mistakes stay small

Trust needs a floor. Adèle signs Claudine's diffs; Alice signs the scenarios; the suite guards the behavior. But somebody — human or AI — will eventually get it wrong anyway, and what happens then decides whether collaboration stays convivial or turns cautious. tilia's answers are specific.

Story

Sunday's nightly import delivers a card with no interval — a malformed row from the shared deck. Somewhere inside, a computed throws. On Alice's phone: the queue still turns, the streak still counts. One card is quietly absent, and one line of red has appeared in the log, pointing at the exact function that choked.

When a callback throws

An exception inside a computed or observe callback could poison the whole reactive graph. Instead, tilia does four things, in order: the exception is caught immediately; the error is logged with a stack trace cleaned of library internals, so the top frame is your code; the faulty observer is cleared, so it cannot block the system; and the error is re-thrown at the end of the next flush, so it still reaches the application's error handling.

One broken observer, one loud report, everyone else keeps working. The reactive system degrades one callback at a time, never as a whole — an application with a bug stays an application.

A bug is a missing scenario

Monday morning, Adèle does what CONTRIBUTING.md says. She does not start with the fix; she starts with the drawing:

Contract
Scenario: an imported card without an interval
  Given an imported card "sol" with no interval
  Then "sol" waits 1 day

Red. Then Claudine makes it green — a malformed import now defaults gently instead of throwing — and the suite is one scenario richer. The bug cannot come back without a red line saying so, and the decision ("waits 1 day") is recorded where all decisions live: in Alice's words, in the ledger, checked forever.

Pro tip

Handle expected failures inside the computed itself — catch and return a fallback. Let the clearing behavior be what it is meant to be: a safety net for the unexpected.

Failing near the cause

The other half of the floor you have already met: in chapter 4, Claudine stranded a computed in a variable and the mistake failed loudly at that line — not three files away as a wrong value. A computed definition is wrapped in a safety proxy: inside a tilia or carve object it unwraps transparently; anywhere else, it throws a descriptive error the moment it is used. Mistakes surface where they are made, which is exactly where a new collaborator — permanently new, in Claudine's case — needs them to surface.

Growth and cleanup

Long-lived apps accumulate and shed observers by the thousand — components mounting and unmounting, sessions starting and ending. Two garbage collectors share the work: JavaScript's own GC releases any tilia object no longer referenced, dependencies and all; tilia's internal GC sweeps the bookkeeping left by cleared observers after a threshold (50 by default). The knob lives on make, which also builds fully isolated reactive contexts — a niche need for libraries and unusual hosts; one context is right for almost every application.

That is the whole safety story, and it is short on purpose: mistakes fail fast and near their cause, crashes stay local, memory is tended without ceremony — and every lesson learned becomes a scenario, so it is only ever learned once. One chapter remains: a step back, to see what was actually built.

Reference: computed, make

12

Onward

Step back and look at what got built, and how. A card is a plain object. A deck is a carved feature: state, derived queue, review action, injected repo and current date. The session is a state machine that only offers legal moves. The views read the domain and repaint exactly when their answer changes. Above it all sits a file of scenarios that Alice — who writes no code — has read, corrected, and signed. Nowhere is there a store, a reducer, an action type, or a subscription list.

The mental model compresses well:

  • The scenarios are the design. Behavior is decided in the domain's words, and the running program is checked against them for life.
  • Objects live. tilia makes a plain object reactive without changing its shape; separate objects share one forest.
  • Values follow. computed and derived keep declared relationships true — pull, cached, nearly free to read.
  • The world is asked for, and flows in. Dependencies arrive injected — a date you can set, a repo in memory — and source and store place external, self-managing values inside the same objects, under the same rules.
  • Time is coherent. Writes notify immediately, except inside reactive callbacks and batches; watch separates cause from effect.
  • Mistakes stay small. Definitions fail loudly at the source; a throwing observer is cleared, reported, and quarantined; every bug becomes a scenario.

And the deeper habit the library rewards is the one named in the first chapter's title: draw before building. It is what let three very different minds — a domain owner, a designer, and an AI who forgets everything between sessions — build one coherent thing without fear. The words carried; the suite verified; the table stayed convivial.

Where to go from here

The API reference documents the complete public surface — every function, both languages, precise rules wherever this guide chose the readable ones.

The scheduler's repo was injected and politely ignored; synchronizing collections with a server — loading, caching, going offline and coming back — is its own discipline, with its own convictions. @tilia/query builds that lifecycle on the reactivity you now understand, and its guide takes Alice somewhere with very bad reception.

The scenarios that ran green through every chapter were executed by épure, published as @epure/vitest — the same project whose CONTRIBUTING.md started the empty directory in chapter 2. The method — software drawn before it is built — lives at epuremethod.com, and its guide is this trilogy's prequel: where the drawings come from.

tilia itself is open source, at github.com/tiliajs/tilia.

Story

Alice knows none of this. She flips a card, the box learns, and tomorrow asks better questions. Adèle rereads the scenarios sometimes, like a diary of decisions. And Claudine remembers nothing at all — which turns out to be fine, because everything that matters is written down, in words all three of them share.