Optimistic state (architect-atom)
Client-side optimistic, stale-while-revalidate state for Dioxus — the client twin of architect's server-side Layer/Resource DI, borrowed from effect-atom without the monad.
architect’s server side borrows Effect’s
Layer / Resource / Scope DI without the Effect<A,E,R> monad (see
idioms §6). architect-atom is the
client-side twin: it borrows
effect-atom’s Result +
writable optimistic atoms — also without the monad — as plain Dioxus
Signals. The result: a UI that feels instant (optimistic
create/update/delete, stale-while-revalidate reads) on top of the same
vox clients, with no second RPC mechanism.
The crate (features/atom) is entity-agnostic and wasm-safe — its only
dependency is Dioxus core. It knows nothing about vox or any concrete
entity; you bind your own type by implementing StoreEntity. It’s
re-exported flat at the architect root under the atom feature, so
consumers write architect::Store / architect::AtomResult /
architect::use_mutation (the architect-atom crate is an implementation
detail).
Why#
The pre-architect-atom screens fetched with use_resource and mutated
with a raw spawn { client.create(..).await; nav.push(..) }. That has
three problems this pattern fixes:
- Empty flashes.
use_resource’sOption<Result<T,E>>drops toNoneon every refetch, blanking the list.AtomResultkeeps the previous value while reloading. - No optimism. A create/delete blocked on the round-trip before the UI changed. Now the row appears/vanishes instantly.
- No rollback. A failed mutation left a stale error string and no way to revert. Now a failure restores the prior state automatically.
The three primitives#
1. AtomResult<T, E = String> — stale-while-revalidate state#
effect-atom’s Result, as a Rust enum that retains the previous value
across a refetch:
pub enum AtomResult<T, E = String> {
Initial,
Loading,
Reloading(T), // refetch in flight, prev value kept
Success(T),
Error { error: E, last: Option<T> }, // expected failure (vox/app error)
Defect { defect: String, last: Option<T> }, // unexpected failure (panic/bug)
}
The phases are flat variants — an expected Error vs an unexpected
Defect (effect’s error/defect distinction) — so a page can
use architect::AtomResult::* and match them unqualified. This is the
idiomatic Rust equivalent of effect’s AsyncResult.matchWithWaiting({ onWaiting, onError, onDefect, onSuccess }) (effect needs an object-matcher
because JS lacks pattern matching; Rust has match):
use architect::AtomResult::*;
match session.state() {
Initial | Loading => rsx! { p { class: "status", "Opening your upload link." } },
Reloading(_prev) => rsx! { p { class: "status", "Refreshing…" } },
Success(s) => rsx! { UploadForm { session: s } },
Error { error, .. } => rsx! { p { class: "status error", "{error}" } },
Defect { defect, .. } => rsx! { p { class: "status error", "{defect}" } },
}
A
Defectis the in-band complement to Dioxus’sErrorBoundary: anErrorBoundarycatches a panic that unwinds out of render; aDefectis a failure you chose to carry as data (AtomResult::Defect { .. }) so the page can render it inline without tearing down the subtree.
2. use_async / Async — a refreshable async value#
The single-resource counterpart (effect-atom’s async atom +
useAtomRefresh). It wraps use_resource and folds it into an
AtomResult, retaining the previous value across a refresh. Read a key
signal inside the loader for the “atom family by key” behaviour:
let session = use_async(move || {
let token = token(); // depends on the route param
async move { client.upload_session(token).await.map_err(|e| format!("{e:?}")) }
});
// session.state() → AtomResult ; session.refresh() → re-run the loader
3. Store + Id + use_mutation — the optimistic keyed cache#
A Store<T> is the collection counterpart (effect-atom’s Atom.family +
writable optimistic atoms): a keyed, ordered cache of an entity, plus the
backing list-fetch phase and a registry of rollback snapshots. Provide one
at the app root next to the connection:
// app root
let store = use_store::<Example, String>();
use_context_provider(|| store);
Bind your entity once (it lives in the proto crate, behind the client-only
atom feature so the server never pulls Dioxus):
impl StoreEntity for Example {
type Key = Uuid;
fn key(&self) -> Uuid { self.id }
}
Id<K> identifies a row as Real(K) or Temp(u64) — a typed temp id,
never a magic "tmp-" string prefix (which the dioxus-MCP
magic_id_prefix_for_optimistic audit flags). On reconcile the temp id is
swapped for the server’s real one.
use_mutation ties the whole optimistic lifecycle into one call, so a call
site can’t forget the rollback arm:
let create = use_mutation::<ExampleClientError>().invalidating(&["examples"]);
let draft = Example::draft(&input); // client-side placeholder row
create.run(
store,
// 1. optimistic patch — runs synchronously, in the handler
move |s| s.insert_optimistic(draft).0,
// 2. the server call — Ok(Some) folds the row in, Ok(None) = body-less
move || async move {
client.create(input).await.map(Some).map_err(ClientError::from)
},
);
// 3. reconcile on Ok (temp→real) / rollback + notification + key
// invalidation on Err — automatic
Store and Mutation are both Copy (they wrap Signals), so they move
into spawn closures with no clone() ceremony — like the existing
clients_of(conn) pattern.
Live data: streams into the store#
Reads don’t have to poll. The streaming layer is modelled on effect’s
PubSub + SubscriptionRef, adapted to wire subscribers:
architect::PubSub<T>(server) — a synchronous, non-blocking fan-out hub:attachthevox::Txsinks that subscribe RPCs receive,publishevents to all of them. Per-subscriber mailboxes with effect-named overflow strategies —sliding(cap)(drop oldest; the default for state-shaped events),dropping(cap)(drop incoming),unbounded()— plus.with_replay(n)to hand late subscribers the lastnevents. A slow subscriber never blocks the writer (the back-pressure strategy is deliberately absent — a host’s main thread can’t suspend), and the buffered attach protocol (begin_attach→ snapshot read →complete_attach) implements effect-SubscriptionRef’s current-state-then-changes without holding a lock across the snapshot.use_stream/use_store_stream(client) — subscribe a component to a server stream for its lifetime; withuse_store_streameach event folds into the optimistic store, so every store-rendered page is live with zero page changes. Ridesuse_resource, so reading theConnectioninside the subscribe future makes reconnects resubscribe automatically.#[architect(events)]on the entity derives the whole CRUD case: the<E>Eventenum (Snapshot(Vec<E>)/Upserted(E)/Deleted(Key)), the subscribe trait, the<E>Evented<R>publish-through wrapper (mount it once;into_router()serves CRUD + the feed; subscribing delivers the snapshot first, so the subscription alone fully hydrates a client store), and the client hook. Custom streams (positions, meters, presence) hand-write a sibling#[vox::service]trait over the samePubSub+use_streamprimitives.
Two rules: wrap the backend in <E>Evented once per process (the hub
lives inside; per-connection routers must share it or broadcasts won’t
cross sockets), and make event payloads carry full state, not diffs —
the changes collected while a snapshot is being read are delivered after
it, so re-application must be idempotent.
The supporting cast#
Four smaller primitives round the layer out:
Connection<C>/use_connect/use_connection— generic connection state (Connecting / Ready / Failed) for a typed client bundle, provided once at the root. Replaces every feature’s hand-rolledConnStateenum.ClientError<E>— the typed client error envelope:App(E)(the service’s typed error, viaFrom<VoxError<E>>) vsConnect/Transport { retryable }infrastructure failures. Hooks returnAtomResult<T, ClientError<E>>, so pages canmatchonApp(RepoError::NotFound).Notifications/provide_notifications— an app-wide queue;Mutation::runreports rollback failures to it automatically, so an optimistic write that navigated away still surfaces its error.Reactivity/provide_reactivity— keyed invalidation (effect-atom’sReactivity.invalidate): loaderstrack(key), settled mutationsinvalidate(key)(wired viause_mutation().invalidating(&[…])), and every tracking loader re-fetches.AtomResult::zip/AtomResult::allcombine several resources into one all-or-nothing phase, anduse_intervalgives cadence-based re-fetch.
How it composes with what’s already there#
The store wraps use_resource, it doesn’t replace it — the
use_store_list / use_store_entry helpers package the pattern: drive a
fetch through use_resource (it re-runs when the connection flips to
Ready, a query changes, or a tracked key is invalidated), fold the
outcome into the store, return entries_result() for the page to match.
Detail/edit screens read cache-first (instant after a list visit) and fall
back to a per-key fetch only on a cache miss — that’s use_store_entry.
Most apps never touch any of this directly: #[architect(store)] on the
entity derives the store binding, the typed hooks, and the optimistic
mutations (see composing the UI).
The primitives are the floor you drop to for feature-specific hooks.
Three rules that keep it correct#
- Patch in handlers, fold in effects — never in render. Optimistic
writes happen in the event handler; fetch results fold into the store in
a
use_effect. Writing signals during render risks an infinite loop (Dioxus: avoid updating state during render). - Mutations outlive the screen.
Mutation::runspawns onspawn_forever, so a create/delete that navigates away the instant it’s issued still reconciles or rolls back the shared store. The store and its snapshots live at the app root; the per-screenpending/errorsignals may be gone, so those writes are guarded. hydratepreserves in-flight temp rows. A list refetch that races an optimistic insert won’t drop the optimistic row.
Relationship to dioxus-fullstack#
None. architect’s UI is client-side-rendered and talks to the server only
through vox (idioms §1), so the Dioxus
fullstack hooks (use_server_future, use_loader, #[server],
dioxus::serve, Lazy<T>) are deliberately unused (review enforces it;
the xtask has no idioms command yet). architect-atom covers the same ground those hooks would
(data loading, refresh, optimistic mutation) without a second RPC stack:
isomorphic loading → use_async; server state → the vox Resource/Layer
graph; error pages → match on AtomResult + Dioxus ErrorBoundary.
Enforced: the primitives are unit-tested in
features/atom(AtomResultSWR transitions, store insert→reconcile temp→real swap, update/remove→rollback, hydrate-preserves-temp). In an app, a feature’s state hooks (data.rs+mutations.rsin itsuimodule) are what the shell’s route pagesmatchand compose.
Linked from
- ClientsThe generated vox and HTTP clients, connecting and the connection lifecycle, the offline queue and optimistic state, forms.
- Atom and formsarchitect-atom: optimistic, stale-while-revalidate client state, a keyed store with temp ids, mutations that patch then reconcile, device rows and an offline call queue; architect-form: typed, validated fields.
- Server push: streams end to endPubSub fan-out, Entity events, #[subscribe] siblings, the client hooks — and the real-time-safety rules.
- Idioms & enforcementThe bearcove/vox + Dioxus conventions architect follows, and what keeps them honest.
- Composing the UIHow architect's Dioxus client is structured: feature crates own state + presentation, the shell owns transport + routing + composition, and pages compose by matching an AtomResult phase.
- Forms (architect-form)Typed, validated form state for Dioxus — the effect-form analog, building on architect-atom and submitting through vox.
- Real-time collaboration (Loro)Offline-first collaborative state: Loro CRDT replicas synced over vox channels — local writes are instant, everything merges, reconnects are deltas.
- App settingsarchitect::settings — a typed Store<T> of facet JSON in the app's config dir (atomic, skip-if-equal, optional 0600), and FileKv.