tilia
Version:
🍃 State management library, domain-driven.
404 lines (301 loc) • 11.2 kB
Markdown
# Tilia LLM Guide (TypeScript)
AI-focused TypeScript documentation for the full Tilia API.
## Install
npm install tilia @tilia/react
## Primary Rule
- Use `carve` as the default for feature modules.
- `carve` binds a feature into one reactive object.
- `carve` must stay glue-only and avoid domain complexity.
- Put behavior in standalone arrow functions that accept `self`.
- Bind with `field: derived(field)` where possible.
## Canonical Carve Pattern
```typescript
import { carve, source } from "tilia";
type Feature = {
count: number;
double: number;
add: (count: number) => void;
};
type Service = {
fetchCount: () => (previous: number, set: (value: number) => void) => void;
updateCount: (next: number, rollback: () => void) => void;
};
const double = (self: Feature): number => self.count * 2;
const add =
(service: Service) =>
(self: Feature) =>
(count: number): void => {
const prevValue = self.count;
self.count += count; // optimistic write
service.updateCount(self.count, () => (self.count = prevValue));
};
export const makeFeature = (service: Service) =>
carve<Feature>(({ derived }) => ({
count: source(0, service.fetchCount()),
double: derived(double),
add: derived(add(service)),
}));
const feature = makeFeature(service);
feature.add(4);
```
## Anti-Pattern (Avoid)
```typescript
import { carve } from "tilia";
// Avoid: logic-heavy body inside carve.
const feature = carve(({ derived }) => ({
count: 0,
double: derived((self) => {
let acc = 0;
for (let i = 0; i < self.count; i++) acc += 2;
return acc;
}),
}));
```
## Feature File Organization
Use this if the project does not provide other guidelines.
```text
[feature-name]/
index.ts // carve glue
type.ts // ts type of the feature
computed.ts // all computed/derived values
actions.ts // all mutating functions
service.ts // external dependencies (db fetch, etc)
```
## Full API Map (TypeScript)
### `tilia`
Wrap object/array into reactive proxy.
```typescript
import { tilia } from "tilia";
const state = tilia({ count: 0 });
state.count = 1;
```
### `carve`
Create feature object and inject `self` via `derived`.
```typescript
import { carve } from "tilia";
type Feature = { value: number; doubled: number };
const doubled = (self: Feature): number => self.value * 2;
const feature = carve<Feature>(({ derived }) => ({
value: 1,
doubled: derived(doubled),
}));
```
### `observe`
Push reactivity callback.
```typescript
import { observe } from "tilia";
observe(() => console.log(state.count));
```
### `watch`
Capture and effect separation.
```typescript
import { watch } from "tilia";
watch(
() => state.count,
(value) => console.log(value),
);
```
### `batch`
Group writes and flush once.
```typescript
import { batch } from "tilia";
batch(() => {
state.a = 1;
state.b = 2;
});
```
### `signal`
Single mutable reactive value and setter.
```typescript
import { signal } from "tilia";
const [count, setCount] = signal(0);
setCount(2);
```
### `derived` (signal API)
Derived signal from signals/reactive reads.
```typescript
import { signal, derived } from "tilia";
const [a, setA] = signal(1);
const b = derived(() => a.value * 2);
setA(2);
```
### `lift`
Expose signal in object as reactive value.
```typescript
import { signal, lift, tilia } from "tilia";
const [s, setS] = signal(0);
const state = tilia({ count: lift(s), setS });
```
### `readonly`
Insert immutable data wrapper without tracking inner mutations.
```typescript
import { tilia, readonly } from "tilia";
const app = tilia({ form: readonly({ version: 1 }) });
```
### `computed`
Pull-based derived value with cache + invalidation.
```typescript
import { computed, tilia } from "tilia";
const state = tilia({
count: 1,
double: computed(() => state.count * 2),
});
```
### `source` (async query and re-query)
Use `source(initial, setup)` for async loading and re-loading.
- **Tracking rule:** Never use `async` in the `setup` function. Tilia tracks reactive reads during synchronous execution of `setup` only. Tracking ONLY HAPPENS INSIDE A CALLBACK. Read dependencies synchronously, then delegate async work.
- `initial` is returned before first `set`.
- `setup(previous, set)` runs on first read.
- `setup(previous, set)` runs again when tracked dependencies in setup change.
- `set(next)` imperatively publishes a new value.
- `previous` is the latest emitted value, useful for incremental updates.
- `previous` also supports stale-while-revalidate UI (keep old data visible, e.g. greyed out, while reloading to avoid blinking).
```typescript
import { carve, source } from "tilia";
const sleep = () => new Promise((resolve) => setTimeout(resolve, 10));
type User = { query: string; name: string };
const fetchName = (self: User) => (previous: string, set: (value: string) => void): void => {
// 1. Synchronous read (tracked)
const q = self.query;
// 2. Delegate async work
sleep().then(() => {
if (q === "helena") set("Helena");
else if (q === "bob") set("William");
else set(`${previous}+${q}`);
});
};
const user = carve<User>(({ derived }) => ({
query: "helena",
name: source("Loading", derived(fetchName)),
}));
// Re-query
user.query = "bob";
```
#### `source` + `derived` inside `carve` (conditional loader strategy)
```typescript
const loader =
(service: Service) =>
(self: { projectId: string }) =>
(previous: Project, set: (value: Project) => void) => {
// 1. Synchronous read (tracked)
const id = self.projectId;
set(stale(previous));
// 2. Delegate async work
service.loadProject(id).then((project) => {
set(loaded(project));
});
};
const selectProject = (self: ProjectBranch) => (id: string) => {
self.projectId = id;
};
const projectBranch = carve<ProjectBranch>(({ derived }) => ({
projectId: "main",
project: source(empty(), derived(loader(service))),
selectProject: derived(selectProject),
}));
```
Use this when loading depends on feature fields (`self.projectId`) and keep `previous` to preserve old UI data until new values are ready.
#### Loading state sentinel pattern
To represent a "loading" state, use an empty tilia object as a sentinel. Use `===` identity comparison to distinguish "loading" from "loaded but empty."
```typescript
const loading = tilia({});
const repo = carve(({ derived }) => ({
data: source(loading, derived(loader(service))),
}));
if (repo.data === loading) { /* still loading */ }
```
### `store`
Managed value with setup that returns current state and receives setter.
```typescript
import { tilia, store } from "tilia";
type Auth = { t: "LoggedOut" } | { t: "LoggedIn" };
const auth = (set: (next: Auth) => void): Auth => ({ t: "LoggedOut" });
const app = tilia({ auth: store(auth) });
```
### `changing` (write tracking for sync connectors)
Track key-level writes on a tilia-proxied dict. Takes an accessor `() => Record<string, T>` so the tracker can follow source swaps. Returns `{ changes, mute }`: `changes` drains accumulated changes into `{ upsert, remove }` when read by `watch`; `mute` runs a callback with tracking suppressed. `upsert` contains objects captured at write time. `remove` contains keys of deleted entries. Last write wins per key. Each call creates an independent accumulator.
- `changing(() => data)` -- returns `{ changes, mute }`. `changes` drains accumulated changes as `{ upsert: T[], remove: string[] }`. `mute(fn)` runs `fn` without tracking (for inbound writes).
- `changing(() => data, guard)` -- optional reactive guard function. When guard returns false, changes accumulate silently. When guard flips to true, all accumulated changes drain and the effect fires.
```typescript
import { tilia, watch, changing } from "tilia";
type Item = { id: string; name: string; quantity: number };
type SyncService = {
upsert: (items: Item[]) => void;
remove: (ids: string[]) => void;
};
type LocalDb = {
upsert: (items: Item[]) => void;
remove: (ids: string[]) => void;
};
type Actor = { online: boolean };
const makeItemsRepo = (service: SyncService, localDb: LocalDb, actor: Actor) => {
const data = tilia<Record<string, Item>>({});
// Local DB: always sync
const { changes } = changing(() => data);
watch(changes, ({ upsert, remove }) => {
localDb.upsert(upsert);
localDb.remove(remove);
});
// Remote: sync only when online
const remote = changing(() => data, () => actor.online);
watch(remote.changes, ({ upsert, remove }) => {
service.upsert(upsert);
service.remove(remove);
});
// Inbound: apply remote data without triggering outbound sync
// remote.mute(() => Object.assign(data, remoteData));
return data;
};
```
Multiple repos use the same pattern:
```typescript
const settings = changing(() => settingsRepo.data, () => actor.online);
watch(settings.changes, ({ upsert, remove }) => {
settingsService.upsert(upsert);
settingsService.remove(remove);
});
```
Architectural summary:
- `source` handles inbound (loading from external into reactive data)
- `changing` + `watch` handles outbound (pushing reactive writes to external)
- `changes` returns `{ upsert, remove }` — upsert contains objects captured at write time, remove contains keys of deleted entries
- `mute` prevents feedback loops: inbound writes are reactive but not tracked
- The guard parameter leverages tilia's natural tracking for offline accumulation
- The accessor pattern `() => data` lets the tracker follow source swaps automatically
### `make`
Create isolated Tilia context.
```typescript
import { make } from "tilia";
const ctx = make();
const local = ctx.tilia({ count: 0 });
```
## React Integration
### Dependency Injection (useApp)
Create a context-based hook to inject the app state. Track with `leaf`, provision app with `useApp`.
```typescript
import { createContext, useContext } from "react";
const AppContext = createContext<App>(emptyApp);
export const useApp = () => {
return useContext(AppContext);
};
```
### Avoid Over-Destructuring
Do not destructure all state variables at the top of a component, as it defeats granular tracking by reading everything immediately.
```typescript
// ❌ BAD: Reads `total` immediately, defeating conditional tracking
const { cart: { total } } = useApp();
// ✅ GOOD: Read properties only when needed in the JSX
const { cart } = useApp();
return <div>{cart.total}</div>;
```
## Generation Rules for AI
- Prefer `carve` for feature boundaries.
- Keep `carve` declarative and short.
- Use arrow functions for helper logic.
- Helpers wired through `derived` must accept `self`.
- Use `source` for async query/re-query flows.
- Explain `set` as imperative emission and `previous` as last emitted value.
- Use `changing(() => data)` + `watch` for outbound sync connectors (persistence, remote sync). Destructure `{ changes, mute }`.
- `changes` returns `{ upsert, remove }` — upsert contains objects, remove contains keys. Use the accessor pattern so the tracker follows source swaps.
- Use `mute` for inbound writes to prevent feedback loops in bidirectional sync.
- `source` = inbound, `changing` = outbound.