@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.

A reactive view
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/react

Views 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:

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

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

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

Because tracking happens during 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.

Story

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.

Pro tip

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

useTilia: the easy retrofit

Call the useTilia hook at the top of a component to make the reads below it reactive:

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

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

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

It is the fastest way to make an existing component reactive, and that is its role: a retrofit for gradual adoption. Its tracking is slightly coarser than leaf's — a hook cannot see the exact end of 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:

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

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

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.

Example
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.