Skip to main content

Stores

A class of atoms is a store. Marking it IArlecchinoStore is all the wiring there is:

public sealed class SettingsStore : IArlecchinoStore
{
public Atom<string> Profile { get; } = new TrackedAtom<string>("");
public Atom<decimal> Volume { get; } = new TrackedAtom<decimal>(60);
public Atom<bool> Fullscreen { get; } = new TrackedAtom<bool>(true);
public Atom<int> Cursor { get; } = new LocalAtom<int>(0);

public Computed<bool> IsComplete { get; }

public SettingsStore() => IsComplete = new(() => Profile.Value.Length > 0);
}

The generator finds it and .AddGeneratedStores() puts it in the container, so views and commands take it as a constructor parameter like any other service — nothing to register by hand and nothing to forget when a store is added.

Two lifetimes

InterfaceLifetimeFor
IArlecchinoStoreSingletonState the whole application shares
IArlecchinoScopedStoreScoped — one per screenState that belongs to one screen and should start fresh next time

Both are empty marker interfaces; the choice of which to implement is the whole configuration.

A scoped store lives as long as the view scope does. Navigating away and coming back builds a new one, so a filter kept in a scoped store resets, and a filter kept in a singleton store does not. That is usually the decision being made.

Why a store rather than fields on a view

A view's fields die with the view, which is right for a cursor and wrong for anything a second screen reads. A store is the place for state with more than one reader:

  • the draft two screens both show,
  • the settings a command writes and a view draws,
  • what a background load produced, so the screen that started it can be left and returned to.

Composing stores

Stores are plain classes resolved from the container, so a store can take another store:

public sealed class DraftStore : IArlecchinoStore
{
private readonly SettingsStore _settings;

public DraftStore(SettingsStore settings)
{
_settings = settings;
Title = new Computed<string>(() => $"{_settings.Profile.Value}{Name.Value}");
}

public Atom<string> Name { get; } = new TrackedAtom<string>("");
public Computed<string> Title { get; }
}

A singleton store cannot take a scoped one — that is the standard container rule, and the container says so at startup rather than at the first navigation.

A store that loads itself

Settings read from disk, a session restored from a server, a catalogue that lives in a file: a store that has to fetch something before it holds the truth derives from ArlecchinoAsyncStore instead, and the framework starts the load as the application starts.

public sealed class SettingsStore : ArlecchinoAsyncStore
{
private const string SettingsPath = "settings.json";

private sealed record Saved(string Server, decimal Port);

public TrackedAtom<string> Server { get; } = new("127.0.0.1");
public TrackedAtom<decimal> Port { get; } = new(40000);

protected override async Task LoadAsync(CancellationToken token)
{
if (!File.Exists(SettingsPath))
{
return;
}

await using var fs = new FileStream(
SettingsPath,
FileMode.Open,
FileAccess.Read,
FileShare.None,
4096,
true);

if (fs.Length == 0)
{
return;
}

var saved = await JsonSerializer.DeserializeAsync<Saved>(fs, cancellationToken: token);
if (saved == null)
{
return;
}

Server.Post(saved.Server);
Port.Post(saved.Port);
}
}

Reading the file is the application's own code — the framework has nothing to do with disks, formats or paths. What it gives is the rest: no BackgroundService to write, no TaskCompletionSource to hand around, and the token is the application's, so a load still running when the user quits is canceled rather than left behind.

A file that is not there yet is not an error here, and neither is an empty one: both checks return early and the atoms stay on the defaults they were declared with — which is what the screen then shows.

LoadAsync runs off the drawing thread, so what it fetches reaches the atoms through Post. Writing Value there throws and says so.

Waiting for it

The first frame is drawn without waiting. A terminal that hangs black on a slow disk is worse than a screen that says it is loading, so the store reports where it got to and the application keeps running:

MemberMeaning
StatusIdle, Loading, Loaded or Failed, as an atom — a view that reads it redraws by itself
IsLoading, IsLoaded, FailedThe same answer as a bool, for an if in Draw
ErrorWhat the load threw, or null
ReadyA Task that completes when the load does

A view reads the status, because it draws every frame anyway:

public void Draw()
{
if (_settings.IsLoading)
{
_surface.AppendLine("loading settings…", Theme.Secondary, Align.Center);
return;
}

_form.Draw(_surface.Content);
}

Code that is not a view — a worker, a command that must not run early — awaits instead:

await _settings.Ready;

Ready faults with whatever LoadAsync threw and is canceled when the application stopped before the load finished, so awaiting it tells you what happened rather than hanging. A store that throws turns its status to failed, is logged, and leaves the application running on whatever its atoms already hold — which is why atoms are declared with sensible defaults.

Often no check is needed at all: Server starts at 127.0.0.1 and simply changes when the load lands. Check where an empty value would lie — a list that is empty until it is loaded, a screen that would otherwise say "0 records".

A store that loads itself is registered like any other, by AddGeneratedStores() or AddStore<T>(). A scoped one is not started by the host: it belongs to a screen rather than to the application, so it loads when that screen builds it.

Persisting a store

Nothing about a store is written to disk for you. Subscribe to what should be saved, or save on the way out:

public sealed class SettingsStore : IArlecchinoStore, IDisposable
{
private readonly IDisposable _watch;

public SettingsStore(SettingsFile file)
{
Profile = new TrackedAtom<string>(file.Read().Profile);
_watch = Profile.Subscribe(() => file.Write(Profile.Value));
}

public Atom<string> Profile { get; }

public void Dispose() => _watch.Dispose();
}

Writing a file on every keystroke is rarely what you want; Ticker.After is the usual way to debounce it.

Reading a store from a widget

Widgets take their rows as data rather than reaching for services, so a view reads the store and hands the widget what it needs. That keeps a widget usable on a screen whose data comes from somewhere else entirely — see Widgets.