State Management
Tesserae state is built on observables — values that emit when they change, and components that re-render when they observe a change. Idiomatic Curiosity front-ends do all of their UI state through these primitives.
Observable<T>
A single mutable value with subscribers.
var name = new Observable<string>("John");
// Component automatically updates when name.Value changes.
TextBlock().Bind(name, n => $"Hello, {n}!");
name.Value = "Jane"; // TextBlock re-renders
Bind is the most common consumer — it takes the observable and a projection from T to text or attributes.
SettableObservable<T>
A read-only-from-outside observable. Use when you want the value to be exposed but not freely mutable:
public class SearchState
{
private readonly SettableObservable<int> _hitCount = new(0);
public IReadOnlyObservable<int> HitCount => _hitCount;
internal void SetHitCount(int n) => _hitCount.Value = n;
}
ObservableList<T>
A list with per-mutation events. Pair with DeferSync (synchronous) or Defer (async) for reactive lists.
var items = new ObservableList<string>();
VStack().Children(
Button("Add").OnClick(() => items.Add($"Item {items.Count + 1}")),
DeferSync(items, list => VStack().Children(list.Select(TextBlock)))
);
ObservableDictionary<K, V> exists with the same shape if your data is keyed.
Defer vs DeferSync
| API | When to use |
|---|---|
DeferSync |
Re-render is cheap and synchronous. Lists, conditional blocks, computed text. |
Defer |
The new content needs an await (an endpoint call, an I/O). Streams in a placeholder while the task runs. |
Combining observables
For derived state, .Combine produces a new observable from two inputs:
var query = new Observable<string>("");
var typeFilter = new Observable<string>("SupportCase");
var hits = Defer(
Observable.Combine(query, typeFilter),
async tuple =>
{
var (q, type) = tuple;
return await Mosaik.API.Endpoints.CallAsync<Hit[]>(
"search", new { q, type });
});
The async call only fires when either input changes, and the result re-renders the Defer.
Anti-patterns
- Mutating
Observable.ValueinsideBind. That creates a re-entrant loop. If you need to react to a change, subscribe withobs.OnChange(...)instead. - Storing whole
Nodeobjects in observables. Store UIDs and re-fetch when needed; nodes can go stale if the underlying data changes. - One mega-observable for the whole page. Decompose — many small observables compose better and update fewer DOM nodes.
Cross-links
- Tesserae basics
- Custom styling
- Routing and navigation —
Routerintegrates with observables for "URL is state".