Documentation Tilia

Guide complet pour comprendre et utiliser Tilia, une bibliothĂšque de gestion d’état simple et performante.

Installation

# Version stable
npm install tilia

# Avec React
npm install tilia @tilia/react

Objectifs et Non-objectifs

L’objectif de Tilia est de fournir une solution de gestion d’état minimale et rapide qui supporte le dĂ©veloppement orientĂ© domaine (comme l’Architecture Clean ou Diagonal). Tilia est conçu pour que votre code ressemble et se comporte comme de la logique mĂ©tier, plutĂŽt que d’ĂȘtre encombrĂ© par des dĂ©tails spĂ©cifiques Ă  la bibliothĂšque.

Non-objectif Tilia n’est pas un framework.

Concepts Fondamentaux

Qu’est-ce que la Programmation RĂ©active Fonctionnelle (FRP) ?

La Programmation Réactive Fonctionnelle (Functional Reactive Programming, FRP) est un paradigme de programmation qui combine deux approches puissantes :

  1. La programmation fonctionnelle : manipulation de données via des fonctions pures, sans effets de bord
  2. La programmation réactive : propagation automatique des changements à travers le systÚme

Le problÚme que résout la FRP

Dans une application traditionnelle, quand une donnĂ©e change, il faut manuellement mettre Ă  jour toutes les parties de l’application qui en dĂ©pendent. Cela mĂšne Ă  du code complexe, fragile et difficile Ă  maintenir :

// ❌ Approche impĂ©rative traditionnelle
let count = 0;
let double = count * 2;
let quadruple = double * 2;

count = 5;
// Oups ! double et quadruple sont maintenant obsolĂštes
// Il faut les recalculer manuellement...
double = count * 2;
quadruple = double * 2;

Avec la FRP, les dépendances sont déclarées une seule fois et les mises à jour se propagent automatiquement :

// ✅ Approche rĂ©active avec Tilia
import { tilia, computed, observe } from "tilia";

const state = tilia({
  count: 0,
  double: computed(() => state.count * 2),
  quadruple: computed(() => state.double * 2),
});

observe(() => {
  console.log(`count=${state.count}, double=${state.double}, quadruple=${state.quadruple}`);
});

state.count = 5;
// ✹ Automatiquement : double=10, quadruple=20
// Le callback observe() est appelé avec les nouvelles valeurs

Les deux modÚles de réactivité

Tilia combine intelligemment deux modÚles de réactivité complémentaires :

Réactivité PUSH (observe, watch)

Le modĂšle push signifie que les changements “poussent” des notifications vers les observateurs. Quand une valeur change, tous les callbacks qui en dĂ©pendent sont automatiquement rĂ©-exĂ©cutĂ©s.

observe(() => {
  // Ce callback sera appelé chaque fois que alice.age change
  console.log("Alice a", alice.age, "ans");
});

alice.age = 11; // ✹ DĂ©clenche automatiquement le callback

Cas d’usage : Effets de bord (logs, mises Ă  jour DOM, appels API), synchronisation d’état.

Réactivité PULL (computed)

Le modĂšle pull signifie que les valeurs sont calculĂ©es paresseusement (lazily), uniquement quand elles sont lues. La valeur est ensuite mise en cache jusqu’à ce qu’une de ses dĂ©pendances change.

const state = tilia({
  items: [1, 2, 3, 4, 5],
  // Calculé seulement quand 'total' est lu
  total: computed(() => state.items.reduce((a, b) => a + b, 0)),
});

// PremiÚre lecture : calcul effectué, résultat mis en cache
console.log(state.total); // 15

// DeuxiÚme lecture : valeur retournée depuis le cache (pas de recalcul)
console.log(state.total); // 15

state.items.push(6); // Invalide le cache

// Lecture aprĂšs modification : recalcul
console.log(state.total); // 21

Cas d’usage : Valeurs dĂ©rivĂ©es, transformations de donnĂ©es, filtres, agrĂ©gations.

Pourquoi combiner les deux ?

ModÚleAvantageInconvénient
PushRĂ©action immĂ©diate aux changementsPeut recalculer inutilement si la valeur n’est pas utilisĂ©e
PullCalcul uniquement si nécessaireNécessite une lecture pour déclencher le calcul

Tilia vous permet de choisir le modÚle approprié selon le contexte, optimisant ainsi les performances tout en gardant un code expressif.

Le Pattern Observer

Le pattern classique

Le pattern Observer (ou Publish-Subscribe) est un design pattern comportemental oĂč un objet, appelĂ© Subject (sujet), maintient une liste d’Observers (observateurs) et les notifie automatiquement de tout changement d’état.

┌─────────────────┐           ┌─────────────────┐
│     Subject     │──notifie──▶│    Observer 1   │
│  (source de     │           ├──────────────────
│   vĂ©ritĂ©)       │──notifie──▶│    Observer 2   │
│                 │           ├──────────────────
│                 │──notifie──▶│    Observer 3   │
└─────────────────┘           └─────────────────┘

Dans l’implĂ©mentation classique, l’observateur doit explicitement s’abonner et se dĂ©sabonner :

// Pattern Observer classique
subject.subscribe(observer);    // Abonnement manuel
// ... plus tard
subject.unsubscribe(observer);  // Désabonnement manuel (source de bugs !)

L’approche Tilia : tracking automatique

Tilia rĂ©volutionne ce pattern en dĂ©tectant automatiquement quelles propriĂ©tĂ©s sont observĂ©es. Pas besoin de s’abonner ou se dĂ©sabonner manuellement !

import { tilia, observe } from "tilia";

const alice = tilia({
  name: "Alice",
  age: 10,
  city: "Paris",
});

observe(() => {
  // Tilia détecte que seuls 'name' et 'age' sont lus
  console.log(`${alice.name} a ${alice.age} ans`);
});

alice.age = 11;     // ✹ DĂ©clenche le callback (age est observĂ©)
alice.city = "Lyon"; // 😮 Ne dĂ©clenche PAS le callback (city n'est pas observĂ©)

Tracking dynamique : seule la derniÚre exécution compte

Un point crucial Ă  comprendre : Tilia ne regarde pas statiquement quelles propriĂ©tĂ©s pourraient ĂȘtre lues dans votre fonction. Il enregistre uniquement les propriĂ©tĂ©s qui ont Ă©tĂ© effectivement lues lors de la derniĂšre exĂ©cution du callback.

Cela signifie que si votre callback contient une condition if, les dépendances changent selon la branche exécutée :

import { tilia, observe } from "tilia";

const state = tilia({
  showDetails: false,
  name: "Alice",
  email: "alice@example.com",
  phone: "01 23 45 67 89",
});

observe(() => {
  // 'name' est TOUJOURS lu
  console.log("Nom:", state.name);
  
  if (state.showDetails) {
    // 'email' et 'phone' ne sont lus QUE si showDetails === true
    console.log("Email:", state.email);
    console.log("Téléphone:", state.phone);
  }
});

// État initial : showDetails = false
// Dépendances actuelles : { name, showDetails }

state.email = "new@email.com";
// 😮 Pas de notification ! 'email' n'a pas Ă©tĂ© lu lors de la derniĂšre exĂ©cution

state.showDetails = true;
// ✹ Notification ! showDetails est observĂ©
// Le callback se ré-exécute, cette fois en lisant email et phone
// Nouvelles dépendances : { name, showDetails, email, phone }

state.email = "another@email.com";
// ✹ Notification ! Maintenant email EST observĂ©

Ce comportement dynamique est extrĂȘmement puissant : vos callbacks ne sont jamais notifiĂ©s pour des valeurs qu’ils n’utilisent pas rĂ©ellement, ce qui optimise automatiquement les performances.

Comment Tilia Construit le Graphe de Dépendances

L’API Proxy de JavaScript

Tilia utilise l’API Proxy de JavaScript pour intercepter les accĂšs aux propriĂ©tĂ©s des objets. Un Proxy est un wrapper transparent qui permet de dĂ©finir des comportements personnalisĂ©s pour les opĂ©rations fondamentales (lecture, Ă©criture, etc.).

// Principe simplifié du Proxy
const handler = {
  get(target, property) {
    console.log(`Lecture de ${property}`);
    return target[property];
  },
  set(target, property, value) {
    console.log(`Écriture de ${property} = ${value}`);
    target[property] = value;
    return true;
  }
};

const obj = { name: "Alice" };
const proxy = new Proxy(obj, handler);

proxy.name;        // Log: "Lecture de name"
proxy.name = "Bob"; // Log: "Écriture de name = Bob"

Le mécanisme de tracking

Quand vous appelez tilia({...}), l’objet est enveloppĂ© dans un Proxy avec deux “traps” (interceptions) essentielles :

1. Le trap GET (lecture)

Quand une propriĂ©tĂ© est lue pendant l’exĂ©cution d’un callback d’observation, Tilia enregistre cette propriĂ©tĂ© comme dĂ©pendance :

// État interne simplifiĂ© de Tilia
let currentObserver = null;  // L'observateur en cours d'exécution
const dependencies = new Map();  // Map: observer -> Set de dépendances

const handler = {
  get(target, key) {
    if (currentObserver !== null) {
      // 📝 Enregistrement de la dĂ©pendance
      // "Cet observateur dépend de cette propriété"
      addDependency(currentObserver, target, key);
    }
    return target[key];
  },
  // ...
};

2. Le trap SET (écriture)

Quand une propriété est modifiée, Tilia trouve tous les observateurs qui en dépendent et les notifie :

const handler = {
  // ...
  set(target, key, value) {
    const oldValue = target[key];
    target[key] = value;
    
    if (oldValue !== value) {
      // 📱 Notification des observateurs
      // "Cette propriété a changé, prévenez tous ceux qui en dépendent"
      notifyObservers(target, key);
    }
    return true;
  }
};

Graphe dynamique

Un point crucial : le graphe de dépendances est dynamique. Il est reconstruit à chaque exécution du callback, ce qui permet de gérer des conditions :

const state = tilia({
  showDetails: false,
  name: "Alice",
  email: "alice@example.com",
});

observe(() => {
  console.log("Nom:", state.name);
  
  if (state.showDetails) {
    // 'email' n'est observé QUE si showDetails est true
    console.log("Email:", state.email);
  }
});

// Dépendances actuelles: {name, showDetails}

state.email = "new@email.com";  // 😮 Pas de notification (email non observĂ©)

state.showDetails = true;       // ✹ Notification + rĂ©-exĂ©cution
// Maintenant les dépendances incluent: {name, showDetails, email}

state.email = "another@email.com"; // ✹ Notification (email est maintenant observĂ©)

Carve et le Domain-Driven Design

Le problÚme de la complexité accidentelle

Dans beaucoup de bibliothĂšques de gestion d’état, le code mĂ©tier finit par ĂȘtre polluĂ© par des concepts techniques. Les dĂ©veloppeurs doivent constamment jongler entre la logique du domaine et les mĂ©canismes rĂ©actifs :

// ❌ Code polluĂ© par les concepts FRP
const personStore = createStore({
  firstName: signal("Alice"),
  lastName: signal("Dupont"),
  fullName: computed(() => 
    personStore.firstName.get() + " " + personStore.lastName.get()
  ),
});

// Pour lire une valeur, il faut "penser FRP"
const nom = personStore.firstName.get();  // .get() ? .value ? ()  ?
personStore.lastName.set("Martin");        // .set() ? .update() ?

Ce code expose la plomberie rĂ©active au lieu du domaine mĂ©tier. L’expert mĂ©tier qui lirait ce code verrait des .get(), .set(), signal() au lieu de voir simplement “une personne avec un nom”.

L’approche Tilia : le domaine d’abord

Avec Tilia, vous manipulez vos objets métier comme des objets JavaScript ordinaires. La réactivité est invisible :

// ✅ Code orientĂ© domaine
const personne = tilia({
  prenom: "Alice",
  nom: "Dupont",
  nomComplet: computed(() => `${personne.prenom} ${personne.nom}`),
});

// Lecture naturelle, comme un objet normal
console.log(personne.prenom);     // "Alice"
console.log(personne.nomComplet); // "Alice Dupont"

// Modification naturelle
personne.nom = "Martin";
console.log(personne.nomComplet); // "Alice Martin" ✹ Automatique

Ici, personne.prenom se lit exactement comme dans n’importe quel code JavaScript. Pas de .get(), pas de .value, pas de fonction Ă  appeler. C’est simplement un objet avec des propriĂ©tĂ©s.

Le langage ubiquitaire (Ubiquitous Language)

Le Domain-Driven Design (DDD) insiste sur l’importance d’un vocabulaire partagĂ© entre dĂ©veloppeurs et experts mĂ©tier. Ce vocabulaire, appelĂ© “langage ubiquitaire”, doit se retrouver directement dans le code.

Tilia facilite cette approche en permettant d’écrire du code qui ressemble au domaine :

// Le code parle le mĂȘme langage que le mĂ©tier
const panier = tilia({
  articles: [],
  codePromo: null,
  
  sousTotal: computed(() => 
    panier.articles.reduce((sum, a) => sum + a.prix * a.quantite, 0)
  ),
  
  reduction: computed(() => 
    panier.codePromo?.pourcentage 
      ? panier.sousTotal * panier.codePromo.pourcentage / 100 
      : 0
  ),
  
  total: computed(() => panier.sousTotal - panier.reduction),
});

// Un expert métier peut lire et comprendre ce code
if (panier.total > 100) {
  appliquerFraisDePortGratuits();
}

Aucune trace de FRP dans ce code. On parle de panier, articles, total - exactement les mĂȘmes termes qu’utiliserait un responsable e-commerce.

Bounded Contexts et modularité

En DDD, un Bounded Context est une limite conceptuelle oĂč un modĂšle particulier est dĂ©fini et applicable. Tilia et carve permettent naturellement de crĂ©er ces frontiĂšres :

// Contexte "Catalogue"
const catalogue = carve<CatalogueContext>(({ derived }) => ({
  produits: [],
  categories: [],
  rechercher: derived((self) => (terme: string) => { /* ... */ }),
  filtrerParCategorie: derived((self) => (cat: string) => { /* ... */ }),
}));

// Contexte "Panier" - modĂšle diffĂ©rent, mĂȘme produit
const panier = carve<PanierContext>(({ derived }) => ({
  lignes: [],  // Pas "produits" - vocabulaire différent dans ce contexte
  ajouter: derived((self) => (produit: Produit, quantite: number) => { /* ... */ }),
  total: derived((self) => /* ... */),
}));

Chaque contexte utilise son propre vocabulaire, ses propres rÚgles, tout en restant réactif.

Guide Pratique

Installation et Premier Pas

Créer un objet réactif

La fonction tilia() transforme un objet JavaScript ordinaire en un objet réactif :

import { tilia } from "tilia";

// Créer un objet réactif
const user = tilia({
  name: "Alice",
  age: 25,
  preferences: {
    theme: "dark",
    language: "fr",
  },
});

// L'utiliser comme un objet normal
console.log(user.name);         // "Alice"
user.age = 26;                  // Modification normale
user.preferences.theme = "light"; // Les objets imbriqués sont aussi réactifs

Points clés :

const todos = tilia({
  items: [
    { id: 1, text: "Apprendre Tilia", done: false },
    { id: 2, text: "Créer une app", done: false },
  ],
});

// Les opérations sur tableaux sont trackées
todos.items.push({ id: 3, text: "Déployer", done: false });
todos.items[0].done = true;

observe

Utilisez observe pour surveiller les changements et réagir automatiquement. Quand une valeur observée change, votre fonction callback est déclenchée (push réactivité).

Pendant l’exĂ©cution du callback, Tilia suit quelles propriĂ©tĂ©s sont accĂ©dĂ©es dans les objets et tableaux connectĂ©s. Le callback s’exĂ©cute toujours au moins une fois lors de la configuration initiale de observe.

import { tilia, observe } from "tilia";

const counter = tilia({ value: 0 });

observe(() => {
  console.log("Compteur:", counter.value);
});
// Output immédiat: "Compteur: 0"

counter.value = 1;  // Output: "Compteur: 1"
counter.value = 2;  // Output: "Compteur: 2"

⚠ Note importante : Si vous modifiez une valeur observĂ©e dans le callback observe, celui-ci sera rĂ©-exĂ©cutĂ© aprĂšs sa fin. Cela permet d’implĂ©menter des machines Ă  Ă©tats.

observe(() => {
  console.log("Valeur:", state.value);
  if (state.value < 10) {
    state.value++;  // ⚠ Provoque une rĂ©-exĂ©cution
  }
});

watch

Utilisez watch de maniĂšre similaire Ă  observe, mais avec une sĂ©paration claire entre la phase de capture et la phase d’effet. La fonction de capture observe les valeurs, et la fonction d’effet est appelĂ©e quand les valeurs capturĂ©es changent.

import { tilia, watch } from "tilia";

const exercise = tilia({ result: "pending" });
const alice = tilia({ score: 0 });

watch(
  // Fonction de capture : définit les dépendances
  () => exercise.result,
  
  // Fonction d'effet : appelée quand les dépendances changent
  (result) => {
    if (result === "pass") {
      alice.score++;  // Cette modification n'est PAS observée
    } else if (result === "fail") {
      alice.score--;
    }
  }
);

exercise.result = "pass";  // ✹ DĂ©clenche l'effet
alice.score = 100;         // 😮 Ne dĂ©clenche PAS l'effet

Différence clé avec observe() :

batch

Groupez plusieurs mises Ă  jour pour Ă©viter les notifications redondantes. Cela peut ĂȘtre nĂ©cessaire pour gĂ©rer des cycles de mise Ă  jour complexes—comme dans les jeux—oĂč les changements d’état atomiques sont essentiels.

💡 Pro tip batch n’est pas requis dans computed, source, store, observe ou watch oĂč les notifications sont dĂ©jĂ  bloquĂ©es.

import { batch } from "tilia";

network.subscribe((updates) => {
  batch(() => {
    for (const update in updates) {
      app.process(update);
    }
  });
  // ✹ Les notifications se produisent ici
});

computed

Retourne une valeur calculée à insérer dans un objet Tilia.

La valeur est calculée quand la clé est lue (pull réactivité) et est détruite (invalidée) quand une valeur observée change.

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"),
  // La valeur 'age' est toujours Ă  jour
  age: computed(() => globals.now.diff(alice.birthday, "year")),
});

💡 Pro tip: Le computed peut ĂȘtre créé n’importe oĂč mais ne devient actif qu’une fois insĂ©rĂ© dans un objet Tilia.

Une fois qu’une valeur est calculĂ©e, elle se comporte exactement comme une valeur rĂ©guliĂšre jusqu’à ce qu’elle expire en raison d’un changement dans les dĂ©pendances. Cela signifie qu’il y a presque zĂ©ro overhead pour les valeurs calculĂ©es agissant comme des getters.

ChaĂźnage de computed

Les valeurs computed peuvent dĂ©pendre d’autres valeurs computed :

const store = tilia({
  items: [
    { price: 100, quantity: 2 },
    { price: 50, quantity: 1 },
  ],
  discount: 0.1,  // 10% de réduction
  
  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 la réduction à 20%
console.log(store.total);  // 200 (250 - 50)

Programmation Réactive Fonctionnelle

✹ Architecte arc-en-ciel, tilia a 7 fonctions supplĂ©mentaires pour vous ! ✹

Avant d’introduire chacune, voici un aperçu.

FonctionCas d’usageParamĂštre treeValeur prĂ©cĂ©denteSetterValeur retournĂ©e
computedValeur calculĂ©e depuis des sources externes❌ Non❌ Non❌ Non✅ Oui
carveCalcul cross-propriĂ©tĂ©âœ… Oui❌ Non❌ Non✅ Oui
sourceMises à jour externes/async❌ Non✅ Oui✅ Oui❌ Non
storeMachine Ă  Ă©tats/logique d’init❌ Non❌ Non✅ Oui✅ Oui
readonlyÉviter le tracking sur donnĂ©es (grandes) en lecture seule

Et quelques sucres syntaxiques :

Fonction Cas d'usage Implémentation
signal Créer une valeur mutable et un setter
const signal = (v) => {
  const s = tilia({ value: v })
  return [s, (v) => { s.value = v }]
}
derived Crée une valeur calculée basée sur d'autres valeurs tilia
const derived = (fn) =>
  signal(computed(fn))
lift Déroule un signal pour l'insérer dans un objet tilia
const lift = (s) => 
  computed(() => s.value)

source

Retourne une source réactive à insérer dans un objet Tilia.

Une source est similaire Ă  un computed, mais elle reçoit une valeur initiale et une fonction setter et ne retourne pas de valeur. Le callback de setup est appelĂ© lors de la premiĂšre lecture de valeur et chaque fois qu’une valeur observĂ©e change. La valeur initiale est utilisĂ©e avant le premier appel Ă  set.

const app = tilia({
  // Rechargeur de données async (setup se ré-exécutera quand l'ùge d'alice change)
  social: source(
    { t: "Loading" },
    (_previous, set) => {
      if (alice.age > 13) {
        fetchData(set);
      } else {
        set({ t: "NotAvailable" });
      }
    }
  ),
  // Abonnement à un événement async (statut en ligne)
  online: source(false, subscribeOnline),
});

Caractéristiques de source() :

store

Retourne une valeur calculée, créée avec un setter qui sera inséré dans un objet Tilia.

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)),
  };
}

💡 Pro tip: store est un pattern trĂšs puissant qui facilite l’initialisation d’une feature dans un Ă©tat spĂ©cifique (pour les tests par exemple).

readonly

Un petit helper pour marquer un champ comme readonly (et ainsi ne pas tracker les changements de ses champs) :

import { type Readonly, readonly } from "tilia";

const app = tilia({
  form: readonly(bigStaticData),
});

// Original `bigStaticData` sans tracking
const data = app.form.data;

// 🚹 'set' on proxy: trap returned falsish for property 'data'
app.form.data = { other: "data" };

signal

Un signal reprĂ©sente une valeur unique et changeante de n’importe quel type.

C’est un petit wrapper autour de tilia pour exposer une valeur unique et changeante ainsi qu’un 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)

đŸŒ± Petit conseil: Utilisez signal pour les calculs d’état et exposez-les avec tilia et lift pour reflĂ©ter votre domaine :

// ✅ OrientĂ© domaine
const [authenticated, setAuthenticated] = signal(false)

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

if (app.authenticated) {
}

derived

CrĂ©e un signal reprĂ©sentant une valeur calculĂ©e. C’est similaire Ă  l’argument derived de carve, mais en dehors d’un objet.

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);

lift

CrĂ©e une valeur computed qui reflĂšte la valeur actuelle d’un signal Ă  insĂ©rer dans un objet Tilia. Utilisez signal et lift pour crĂ©er un Ă©tat privĂ© et exposer des valeurs en lecture seule.

// Implémentation de lift
function lift<T>(s: Signal<T>): T {
  return computed(() => s.value);
}

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

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

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

✹ Carving ✹

carve

C’est lĂ  que Tilia brille vraiment. Il vous permet de construire une feature orientĂ©e domaine, autonome, facile Ă  tester et Ă  rĂ©utiliser.

const feature = carve(({ derived }) => { ... fields })

La fonction derived dans l’argument de carve est comme un computed mais avec l’objet lui-mĂȘme comme premier paramĂštre.

Exemple

import { carve, source } from "tilia";

// Une fonction pure pour trier les todos, facile à tester isolément.
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);
}

// Une fonction pure pour basculer un todo, également facilement 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`);
    }
  };
}

// Injection de la dépendance "repo"
function makeTodos(repo: Repo) {
  // ✹ Sculpter la feature todos ✹
  return carve({ derived }) => ({
    sort: "by date",
    list: derived(list),
    data: source([], repo.fetchTodos),
    toggle: derived(toggle),
    repo,
  });
}

💡 Pro tip: Le carving est un moyen puissant de construire des features orientĂ©es domaine et autonomes. Extraire la logique en fonctions pures (comme list et toggle) facilite les tests et la rĂ©utilisation.

Dérivation récursive (machines à états)

Pour la dérivation récursive (comme les machines à états), utilisez source :

derived((tree) => source(initialValue, machine));

Cela vous permet de crĂ©er un Ă©tat dynamique ou auto-rĂ©fĂ©rentiel qui rĂ©agit aux changements dans d’autres parties de l’arbre.

💡

Différence avec computed

Regardez todos.ts pour un exemple d’utilisation de carve pour construire la feature todos.

Intégration React

useTilia (React Hook)

Installation

npm install @tilia/react

Insérez useTilia en haut des composants React qui consomment des valeurs tilia.

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

function App() {
  useTilia();

  if (alice.age >= 13) {
    return <SocialMediaApp />;
  } else {
    return <NormalApp />;
  }
}

Le composant App se re-rendra maintenant quand alice.age change parce que “age” a Ă©tĂ© lu depuis “alice” pendant le dernier render.

leaf (React Higher Order Component)

C’est la mĂ©thode recommandĂ©e pour crĂ©er des composants rĂ©actifs. ComparĂ© Ă  useTilia, ce tracking est exact grĂące au tracking propre dĂ©but/fin de la phase de render qui n’est pas faisable avec les hooks.

Installation

npm install @tilia/react

Enveloppez votre composant avec leaf :

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

// Utilisez une fonction nommée pour avoir des noms de composants appropriés dans React dev tools.
const App = leaf(function App() {
  if (alice.age >= 13) {
    return <SocialMediaApp />;
  } else {
    return <NormalApp />;
  }
});

Le composant App se re-rendra maintenant quand alice.age change parce que “age” a Ă©tĂ© lu depuis “alice” pendant le dernier render.

useComputed (React Hook)

useComputed vous permet de calculer une valeur et de ne re-rendre que si le résultat change.

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>;
}

Avec ce helper, TodoView ne dĂ©pend pas de app.todos.selected.id mais de selected.value. Cela empĂȘche le composant de re-rendre Ă  chaque changement du todo sĂ©lectionnĂ©.

Référence Technique Approfondie

Architecture Interne

Structure du Proxy Handler

Voici une représentation simplifiée du handler Proxy utilisé par Tilia :

// Simplifié pour la compréhension
const createHandler = (context: TiliaContext) => ({
  get(target: object, key: string | symbol, receiver: unknown) {
    // 1. Ignorer les symboles et propriétés internes
    if (typeof key === "symbol" || key.startsWith("_")) {
      return Reflect.get(target, key, receiver);
    }
    
    // 2. Enregistrer la dépendance si un observer est actif
    if (context.currentObserver !== null) {
      context.addDependency(context.currentObserver, target, key);
    }
    
    // 3. Récupérer la valeur
    const value = Reflect.get(target, key, receiver);
    
    // 4. Si c'est un objet, le wrapper récursivement
    if (isObject(value) && !isProxy(value)) {
      return createProxy(value, context);
    }
    
    // 5. Si c'est un computed, l'exécuter
    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. Effectuer la modification
    const result = Reflect.set(target, key, value, receiver);
    
    // 2. Notifier si la valeur a changé
    if (!Object.is(oldValue, value)) {
      context.notify(target, key);
    }
    
    return result;
  },
  
  deleteProperty(target: object, key: string | symbol) {
    const result = Reflect.deleteProperty(target, key);
    
    // Notifier de la suppression
    if (result) {
      context.notify(target, key);
    }
    
    return result;
  },
  
  ownKeys(target: object) {
    // Tracker l'itération sur les clés
    if (context.currentObserver !== null) {
      context.addDependency(context.currentObserver, target, KEYS_SYMBOL);
    }
    return Reflect.ownKeys(target);
  },
});

Cycle de vie d’un computed

┌─────────────────────────────────────────────────────────────┐
│                    ÉTAT INITIAL                              │
│  computed créé mais pas encore exĂ©cutĂ©                       │
│  cache = EMPTY, valid = false                                │
└─────────────────────────────────────────────────────────────┘
                            │
                            â–Œ (premiĂšre lecture)
┌─────────────────────────────────────────────────────────────┐
│                    EXÉCUTION                                 │
│  1. currentObserver = ce computed                            │
│  2. ExĂ©cution de la fonction                                 │
│  3. DĂ©pendances enregistrĂ©es pendant l'exĂ©cution            │
│  4. cache = rĂ©sultat, valid = true                          │
│  5. currentObserver = null                                   │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▌ (lectures suivantes)
┌─────────────────────────────────────────────────────────────┐
│                    CACHE HIT                                 │
│  valid = true → retourne cache directement                  │
│  Aucun recalcul                                              │
└─────────────────────────────────────────────────────────────┘
                            │
                            â–Œ (dĂ©pendance change)
┌─────────────────────────────────────────────────────────────┐
│                    INVALIDATION                              │
│  1. SET dĂ©tectĂ© sur une dĂ©pendance                          │
│  2. valid = false                                            │
│  3. Notification propagĂ©e aux observateurs                  │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▌ (prochaine lecture)
┌─────────────────────────────────────────────────────────────┐
│                    RE-EXÉCUTION                              │
│  MĂȘme processus que EXÉCUTION                                │
│  Nouvelles dĂ©pendances potentiellement diffĂ©rentes          │
└─────────────────────────────────────────────────────────────┘

Forest Mode

Tilia supporte le “Forest Mode” oĂč plusieurs objets tilia() sĂ©parĂ©s peuvent ĂȘtre observĂ©s ensemble :

const alice = tilia({ name: "Alice", age: 10 });
const bob = tilia({ name: "Bob", age: 12 });

// Un seul observe qui dépend de DEUX arbres
observe(() => {
  console.log(`${alice.name} a ${alice.age} ans`);
  console.log(`${bob.name} a ${bob.age} ans`);
});

alice.age = 11;  // ✹ DĂ©clenche l'observe
bob.age = 13;    // ✹ DĂ©clenche aussi l'observe

Ce fonctionnement est possible grùce au contexte global partagé qui maintient les dépendances de tous les arbres.

Le “Glue Zone” et la SĂ©curitĂ© (v4)

Le problĂšme des Orphan Computations

Avant la v4, il Ă©tait possible de crĂ©er un computed en dehors d’un objet Tilia, ce qui causait des erreurs obscures :

// ❌ DANGER : computed créé "dans le vide"
const trouble = computed(() => count.value * 2);

// Plus tard, accÚs en dehors d'un contexte réactif
const crash = trouble * 2;  // đŸ’„ Erreur obscure !

La “Glue Zone”

La “Glue Zone” est la zone dangereuse oĂč une dĂ©finition de computation existe sans ĂȘtre attachĂ©e Ă  un objet. En v4, Tilia ajoute des protections pour Ă©viter ce problĂšme.

// AVANT (Glue Zone - dangereux)
const computed_def = computed(() => x.value * 2);
// 'computed_def' est un "fantÎme" - ni une valeur, ni attaché à un objet

// APRÈS (insertion dans un objet - sûr)
const obj = tilia({
  double: computed(() => x.value * 2)  // ✅ Créé directement dans l'objet
});

Safety Proxies (v4)

En v4, les définitions de computation (computed, source, store) sont enveloppées dans un Safety Proxy :

const [count, setCount] = signal(0);

// ❌ CrĂ©ation d'un orphan
const orphan = computed(() => count.value * 2);

// đŸ›Ąïž v4 Protection: Lance une erreur claire
const result = orphan * 2;
// Error: "Orphan computation detected. computed/source/store must be
// created directly inside a tilia or carve object."

Rùgle d’or

NE JAMAIS assigner le rĂ©sultat d’un computed, source ou store Ă  une variable intermĂ©diaire.
TOUJOURS les définir directement dans un objet tilia() ou carve().

// ❌ Mauvais
const myComputed = computed(() => ...);
const obj = tilia({ value: myComputed });

// ✅ Bon
const obj = tilia({
  value: computed(() => ...)
});

Stratégie de Flush et Batching

Deux comportements selon le contexte

Le moment oĂč Tilia notifie les observateurs dĂ©pend de oĂč la modification a lieu :

ContexteComportementExemple
Hors observationFlush immédiatCode dans un event handler, setTimeout, etc.
Dans un contexte d’observationFlush diffĂ©rĂ©Dans computed, observe, watch, leaf, useTilia

Hors contexte d’observation : flush immĂ©diat

Quand vous modifiez une valeur en dehors d’un contexte d’observation, chaque modification dĂ©clenche immĂ©diatement une notification :

const state = tilia({ a: 1, b: 2 });

observe(() => {
  console.log(`a=${state.a}, b=${state.b}`);
});
// Output: "a=1, b=2"

// Hors contexte d'observation (ex: dans un event handler)
state.a = 10;
// ⚡ Notification IMMÉDIATE !
// Output: "a=10, b=2"

state.b = 20;
// ⚡ Notification IMMÉDIATE !
// Output: "a=10, b=20"

Le problÚme des états transitoires incohérents

Ce comportement peut causer des problÚmes quand plusieurs propriétés doivent changer ensemble de maniÚre cohérente :

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"

// On veut passer Ă  200x100 (mĂȘme ratio)
rect.width = 200;
// ⚠ État transitoire incohĂ©rent !
// Output: "Dimensions: 200x50, ratio: 4"  ← ratio incorrect !

rect.height = 100;
// Output: "Dimensions: 200x100, ratio: 2"  ← correct maintenant

L’observateur a vu un Ă©tat intermĂ©diaire oĂč le ratio Ă©tait de 4, ce qui n’était jamais l’intention.

batch() : la solution pour les modifications groupées

batch() permet de regrouper plusieurs modifications et de ne notifier qu’une seule fois à la fin :

import { batch } from "tilia";

// ✅ Avec batch : une seule notification cohĂ©rente
batch(() => {
  rect.width = 200;
  rect.height = 100;
  // Aucune notification pendant le batch
});
// ✹ Une seule notification ici
// Output: "Dimensions: 200x100, ratio: 2"

Cas d’usage typiques pour batch() :

Dans un contexte d’observation : flush diffĂ©rĂ© automatique

À l’intĂ©rieur d’un callback computed, observe, watch, ou d’un composant avec leaf/useTilia, les notifications sont automatiquement diffĂ©rĂ©es. Pas besoin d’utiliser batch() :

const state = tilia({
  items: [],
  processedCount: 0,
});

observe(() => {
  // Dans un contexte d'observation, les modifications sont batchées
  for (const item of incomingItems) {
    state.items.push(item);
    state.processedCount++;
    // Pas de notification ici, mĂȘme si des observateurs regardent ces valeurs
  }
  // ✹ Notifications à la fin du callback
});

Mutations récursives dans observe

Si vous modifiez une valeur observĂ©e par le mĂȘme callback dans observe, celui-ci sera planifiĂ© pour une rĂ©-exĂ©cution aprĂšs la fin de l’exĂ©cution actuelle :

observe(() => {
  console.log("Value:", state.value);
  
  if (state.value < 5) {
    state.value++;  // Planifie une nouvelle exécution
  }
});

// Output:
// "Value: 0"
// "Value: 1"
// "Value: 2"
// "Value: 3"
// "Value: 4"
// "Value: 5"

⚠ Attention : Cette fonctionnalitĂ© est puissante mais peut crĂ©er des boucles infinies si mal utilisĂ©e.

Mutations dans computed : risque de boucle infinie

Le principal danger des mutations dans un computed est le risque de boucle infinie : si le computed lit la valeur qu’il modifie, il s’invalide lui-mĂȘme et tourne en boucle.

const state = tilia({
  items: [] as number[],
  
  // ❌ DANGER : le computed lit ET modifie 'items'
  count: computed(() => {
    const len = state.items.length;  // Lecture de 'items'
    state.items.push(len);           // Écriture dans 'items' → invalide le computed !
    return len;                      // → Recalcul → Lecture → Écriture → ∞
  }),
});

// Accéder à state.count provoque une boucle infinie !

Le problùme : Le computed observe items, puis le modifie, ce qui l’invalide et provoque un nouveau calcul, qui observe à nouveau, modifie à nouveau, etc.

Solution : utiliser watch pour séparer observation et mutation

watch sépare clairement :

const state = tilia({
  count: 0,
  history: [] as number[],
});

// ✅ BON : watch sĂ©pare observation et mutation
watch(
  () => state.count,              // Observation : trackée
  (count) => {
    state.history.push(count);    // Mutation : pas de tracking ici
  }
);

state.count = 1;  // history devient [1]
state.count = 2;  // history devient [1, 2]

Avec watch, la mutation dans le second callback n’est pas trackĂ©e, donc elle ne peut pas crĂ©er de boucle mĂȘme si elle lit et modifie les mĂȘmes valeurs.

Garbage Collection

Ce que gĂšre le GC natif de JavaScript

Le garbage collector natif de JavaScript gĂšre trĂšs bien la libĂ©ration des objets trackĂ©s qui ne sont plus utilisĂ©s en mĂ©moire. Si un objet tilia({...}) n’est plus rĂ©fĂ©rencĂ© nulle part, JavaScript le libĂšre automatiquement, ainsi que toutes ses dĂ©pendances internes.

Vous n’avez rien à faire pour cela : c’est le comportement standard de JavaScript.

Ce que gĂšre le GC de Tilia

Pour chaque propriĂ©tĂ© observĂ©e, Tilia maintient une liste de watchers. Quand un watcher est “cleared” (par exemple, quand un composant React se dĂ©monte), il est retirĂ© de la liste, mais la liste elle-mĂȘme (mĂȘme vide) reste attachĂ©e Ă  la propriĂ©tĂ©.

Ces listes vides représentent trÚs peu de données, mais Tilia les nettoie périodiquement :

import { make } from "tilia";

// Configuration du seuil GC
const ctx = make({
  gc: 100,  // Déclenche le nettoyage aprÚs 100 watchers cleared
});

// Le seuil par défaut est 50

Quand le nettoyage se déclenche

  1. Un watcher est “cleared” (composant dĂ©montĂ©, etc.)
  2. Le compteur clearedWatchers s’incrĂ©mente
  3. Si clearedWatchers >= gc, nettoyage de la liste des watchers
  4. clearedWatchers reset Ă  0

Configuration selon l’application

// Application avec beaucoup de composants dynamiques (listes, onglets, modales)
const ctx = make({ gc: 200 });

// Application plus stable avec peu de montages/démontages
const ctx = make({ gc: 30 });

En pratique, le seuil par défaut (50) convient à la plupart des applications.

Gestion des Erreurs

Erreurs dans computed et observe

Quand une exception est levĂ©e dans un callback computed ou observe, Tilia adopte une stratĂ©gie de report d’erreur pour Ă©viter de bloquer l’application :

  1. L’exception est capturĂ©e immĂ©diatement
  2. L’erreur est loguĂ©e dans console.error avec une stack trace nettoyĂ©e
  3. L’observer fautif est nettoyĂ© (cleared) pour Ă©viter de bloquer le systĂšme
  4. L’erreur est relancĂ©e Ă  la fin du prochain flush
const state = tilia({
  value: 0,
  computed: computed(() => {
    if (state.value === 42) {
      throw new Error("La réponse universelle est interdite !");
    }
    return state.value * 2;
  }),
});

observe(() => {
  console.log("Computed:", state.computed);
});

// Tout fonctionne
state.value = 10;  // Log: "Computed: 20"

// Déclenche une erreur
state.value = 42;
// 1. L'erreur est loguée immédiatement dans console.error
// 2. L'observer est nettoyé
// 3. L'erreur est relancée à la fin du flush

Pourquoi diffĂ©rer l’erreur ?

Ce comportement permet de :

  1. Ne pas bloquer les autres observers : Si un observer crashe, les autres continuent de fonctionner
  2. Garder l’application stable : Le systĂšme rĂ©actif n’est pas verrouillĂ© par une erreur
  3. Logger immĂ©diatement : L’erreur apparaĂźt dans la console dĂšs qu’elle se produit
  4. Propager l’erreur : L’exception remonte quand mĂȘme pour ĂȘtre gĂ©rĂ©e par l’application

Stack trace nettoyée

Pour faciliter le dĂ©bogage, Tilia nettoie la stack trace en retirant les lignes internes de la bibliothĂšque. Vous voyez directement oĂč l’erreur s’est produite dans votre code :

Exception thrown in computed or observe
    at myComputed (src/domain/feature.ts:42:15)
    at handleClick (src/components/Button.tsx:18:5)

Bonnes pratiques

// ✅ GĂ©rer les cas d'erreur dans le computed
const state = tilia({
  data: computed(() => {
    try {
      return riskyOperation();
    } catch (e) {
      console.error("Opération échouée:", e);
      return { error: true, message: e.message };
    }
  }),
});

// ✅ Utiliser des valeurs par dĂ©faut
const state = tilia({
  user: computed(() => fetchedUser ?? { name: "Anonyme" }),
});
Comparaison avec... GitHub

Fonctionnalités Principales

✓ ZĂ©ro dĂ©pendances
✓ OptimisĂ© pour la stabilitĂ© et la vitesse
✓ RĂ©activitĂ© hautement granulaire
✓ Combine la rĂ©activitĂ© pull et push
✓ Le tracking suit les objets dĂ©placĂ©s ou copiĂ©s
✓ Compatible avec ReScript et TypeScript
✓ Calculs optimisĂ©s (pas de recalcul, traitement par batch)
✓ Empreinte rĂ©duite (8KB) ✹