Curiosity

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.Value inside Bind. That creates a re-entrant loop. If you need to react to a change, subscribe with obs.OnChange(...) instead.
  • Storing whole Node objects 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.
© 2026 Curiosity. All rights reserved.