The scheduler gets its face on a Saturday. Alice answers, the queue advances, and only the card on screen repaints; the streak counter, deck list, and settings panel never notice. It feels less like a program updating and more like a page turning.
@tilia/react·React & React Native·MIT
Views That Observe the Domain
The view adapter for tilia. A component reads the domain while rendering and repaints exactly when one of the values it read changes — no selectors, no dependency arrays, no memo rituals. The same three tools serve React and React Native.
feuille /fœj/ n., f. — the leaf. Where the wood meets the light; it turns with the season, the trunk does not.
const CardView = leaf(() => {
const { deck } = useApp()
const card = deck.queue[0]
// repaints when deck.queue changes,
// and not otherwise
return card
? <div>{card.front}</div>
: <AllDone />
})
let make = leaf(() => {
let {deck} = useApp()
// repaints when deck.queue changes,
// and not otherwise
switch deck.queue[0] {
| Some(card) => <div> {card.front->React.string} </div>
| None => <AllDone />
}
})
$ npm install tilia @tilia/reactViews are observers
That one idea is the entire adapter: a component reads reactive values while rendering and should re-render exactly when one of those values changes. Three tools support this, in a deliberate order of preference.
leaf: the preferred way
leaf wraps a component so that tilia tracks the reads made during rendering:
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 rendering, 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. Its vocabulary remains that of the domain — deck, queue, front: a view the domain owner could read over the developer's shoulder.
useApp is an architectural suggestion, not an API: provide the app object through an ordinary React context and let each component pull the feature it needs. Because tracking is fine-grained, one context for the whole app works seamlessly — and tests can provide a mock app in the same way. The world stays injected, even here.
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
Call the useTilia hook at the top of a component to make the reads below it reactive:
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 the component's render — so prefer leaf for new code. The API reference explains the precise mechanics.
useComputed: re-render on the answer
Sometimes a component depends on a conclusion, not the values behind it. Each row of a 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 changes:
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.
React Native, unchanged
Nothing in tilia knows about the DOM, and nothing in @tilia/react knows about a renderer. Install the same two packages, import the same three tools, swap the elements.
import { leaf } from "@tilia/react";
import { Text, View } from "react-native";
const CardView = leaf(() => {
const { deck } = useApp();
const card = deck.queue[0];
return (
<View>
<Text>{card ? card.front : "All done"}</Text>
</View>
);
});
open TiliaReact
@react.component
let make = leaf(() => {
let {deck} = useApp()
let front = deck.queue[0]->Option.mapOr("All done", c => c.front)
<View> <Text> {front->React.string} </Text> </View>
})
The adapter is the same because the reactivity is the same: plain objects, tracked reads, exact repaints. Whether you use Expo or a bare workflow, one domain serves iOS, Android, and the web — with no native module, configuration, or separate package to learn.
Start with the domain
Everything above assumes the interesting part is already built and tested without a pixel: the deck, the session, today's date, the streak.
If you arrived here first, read the tilia guide once, from top to bottom. It builds a small application in the domain's own words — plain objects, values that follow, a world that is injected — and culminates in views are observers, where this page picks up. The API reference covers the adapter's complete surface in both languages; the core functions are in the tilia API reference.