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
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 :
- Lâobjet retournĂ© se comporte exactement comme un objet normal
- Tous les objets imbriqués sont automatiquement rendus réactifs
- Les tableaux sont également supporté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() :
- Dans
watch, les modifications dans lâeffet ne dĂ©clenchent pas de rĂ©-exĂ©cution - Utile pour Ă©viter les boucles infinies dans les cas complexes
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.
| Fonction | Cas dâusage | ParamĂštre tree | Valeur prĂ©cĂ©dente | Setter | Valeur retournĂ©e |
|---|---|---|---|---|---|
computed | Valeur calculĂ©e depuis des sources externes | â Non | â Non | â Non | â Oui |
carve | Calcul cross-propriĂ©tĂ© | â Oui | â Non | â Non | â Oui |
source | Mises Ă jour externes/async | â Non | â Oui | â Oui | â Non |
store | Machine Ă Ă©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 |
|
derived |
Crée une valeur calculée basée sur d'autres valeurs tilia |
|
lift |
Déroule un signal pour l'insérer dans un objet tilia |
|
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() :
- Reçoit la valeur précédente comme premier argument du callback
- Le callback est ré-exécuté quand ses dépendances changent
- Idéal pour les loaders de données réactifs
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
- Utilisez
computedpour les valeurs dĂ©rivĂ©es pures qui ne dĂ©pendent pas de lâobjet entier. - Utilisez
derived(viacarve) quand vous avez besoin dâaccĂ©der Ă lâobjet rĂ©actif complet pour la logique cross-propriĂ©tĂ© ou les mĂ©thodes.
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 :
- Dans un contexte rĂ©actif (tilia/carve) : le proxy sâunwrap transparemment
- En dehors : le proxy lance une erreur descriptive
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,sourceoustoreà une variable intermédiaire.
TOUJOURS les définir directement dans un objettilia()oucarve().
// â 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 :
| Contexte | Comportement | Exemple |
|---|---|---|
| Hors observation | Flush immédiat | Code dans un event handler, setTimeout, etc. |
| Dans un contexte dâobservation | Flush 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() :
- Event handlers qui modifient plusieurs propriétés
- Callbacks de WebSocket/SSE avec mises Ă jour multiples
- Initialisation de plusieurs valeurs
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 :
- La phase dâobservation (premier callback) : trackĂ©e, dĂ©finit les dĂ©pendances
- La phase de mutation (second callback) : sans tracking, pas de risque de boucle
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
- Un watcher est âclearedâ (composant dĂ©montĂ©, etc.)
- Le compteur
clearedWatcherssâincrĂ©mente - Si
clearedWatchers >= gc, nettoyage de la liste des watchers clearedWatchersreset Ă 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 :
- Lâexception est capturĂ©e immĂ©diatement
- Lâerreur est loguĂ©e dans
console.erroravec une stack trace nettoyĂ©e - Lâobserver fautif est nettoyĂ© (cleared) pour Ă©viter de bloquer le systĂšme
- 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 :
- Ne pas bloquer les autres observers : Si un observer crashe, les autres continuent de fonctionner
- Garder lâapplication stable : Le systĂšme rĂ©actif nâest pas verrouillĂ© par une erreur
- Logger immĂ©diatement : Lâerreur apparaĂźt dans la console dĂšs quâelle se produit
- 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" }),
});
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 :
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 :
Avec la FRP, les dépendances sont déclarées une seule fois et les mises à jour se propagent automatiquement :
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.
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.
Cas dâusage : Valeurs dĂ©rivĂ©es, transformations de donnĂ©es, filtres, agrĂ©gations.
Pourquoi combiner les deux ?
Tilia vous permet de choisir le modÚle approprié selon le contexte, optimisant ainsi les performances tout en gardant un code expressif.