13 KiB
Architecture Patterns
Domain: Runtime workspace state flow for existing Tauri + React desktop app Researched: 2026-03-28 Overall confidence: MEDIUM-HIGH
Recommended Architecture
Use a feature-scoped runtime workspace controller with three explicit state domains instead of one polling snapshot flowing directly into both status UI and form inputs.
- Live runtime state - polled, read-only, owned by query state
- Persisted settings state - latest saved config from Tauri, read-only except after save success
- Draft settings state - local editable form state, owned only by the settings editor
That separation is the core fix. The current problem comes from RuntimeSettingsDrawer.tsx rehydrating local input state from snapshot.config on every snapshot change while useRuntimeManager.ts refreshes the whole RuntimeSnapshot every 3 seconds. Polling is correct for live runtime health, but it must not be the owner of editable inputs.
Recommended shape:
Tauri commands / file persistence / process state
|
v
Runtime query layer (polling live snapshot + persisted config)
|
+--> Status/summary components (read-only)
|
+--> Draft controller initializer/reset source
|
v
Editable settings form (local reducer/state)
|
v
Save mutation
|
v
update persisted config -> invalidate/refetch live query
Recommended Component Boundaries
| Component | Responsibility | Communicates With |
|---|---|---|
RuntimeWorkspaceProvider |
Feature root; composes live query, command mutations, and draft provider | Tauri API adapter, child panels |
useRuntimeWorkspaceQuery |
Polls runtime status and loads latest persisted config; exposes data, isFetching, refetch |
api.ts, query cache |
useRuntimeCommands |
Starts, stops, saves, rescans; serializes mutations and invalidates/refetches workspace query after success | Tauri commands, query cache |
RuntimeStatusPanel |
Renders runtime readiness, last error, endpoint, process state, restart-required banners | live query only |
RuntimeSettingsDraftProvider |
Owns draft settings, dirty fields, reset/cancel, stale-baseline detection | workspace query, settings form |
RuntimeSettingsForm |
Pure controlled inputs for local/remote settings | draft provider only |
RuntimeModelLibraryPanel |
Reads managed library, scanned models, search results; writes selections into draft only | aux queries, draft provider |
RuntimePrimaryActions |
Save, cancel, start, stop, save-and-start logic; decides enablement from live state + draft state | commands, live query, draft provider |
Data Model Recommendation
Do not let the frontend treat one RuntimeSnapshot object as the source of truth for everything. Normalize it into these view models inside the runtime feature:
1. Live Snapshot
Read-only runtime state used for polling-sensitive UI.
type LiveRuntimeSnapshot = {
status: RuntimeStatus;
ready: boolean;
processRunning: boolean;
resolvedUrl: string;
binaryPath: string | null;
lastError: RuntimeErrorInfo | null;
};
2. Persisted Config Baseline
Latest saved config returned from Tauri. This is the reset target for the form.
type PersistedRuntimeConfig = RuntimeConfig & {
revision: number | string;
};
If possible, add a monotonic revision or updated_at field on the Tauri side. Without it, the frontend can still compare deep equality, but a revision is cleaner for stale-draft detection and cancel/reset behavior.
3. Draft Config
Local editable state that never changes because polling ran.
type RuntimeDraftState = {
values: RuntimeConfig;
dirtyFields: Set<string>;
initializedFromRevision: number | string | null;
saveState: "idle" | "saving" | "error";
};
Data Flow
Polling Flow: live snapshot vs draft settings
useRuntimeWorkspaceQuerybootstraps from Tauri and continues polling on an interval.- Poll responses update live snapshot immediately.
- Poll responses update persisted config baseline only when the returned config revision changed.
RuntimeStatusPanelrerenders from live snapshot on every poll.RuntimeSettingsDraftProviderdoes not overwrite draft values during normal polling.- If the baseline revision changes while the draft is dirty, show a non-destructive "saved settings changed" notice with actions:
Reload saved,Keep editing.
This matches React guidance: controlled inputs should stay owned by local state, and state should not be repeatedly reset from props/effects unless the user is intentionally switching context.
Open / Close Drawer Flow
- Opening settings initializes draft from the latest persisted config baseline.
- While the drawer stays open, the draft is the only owner of form fields.
- Closing with cancel discards the draft and clears dirty state.
- Reopening creates a fresh draft from the current persisted baseline.
If the app keeps the editor mounted, key the draft provider by baseline.revision only when the user explicitly resets/cancels. Do not key it by every poll result.
Save Flow
- User edits draft values locally.
saveRuntimeConfigmutation sends the full draft payload.- On success, mutation updates the persisted baseline from the response and clears dirty state.
- Then invalidate/refetch the live workspace query.
- Status components pick up any restart-required or readiness change from the fresh live snapshot.
If the response already contains the updated snapshot/config, update query cache directly first and refetch after only when needed. That follows TanStack Query guidance: mutations own writes; queries are refreshed by invalidation or cache updates.
Start / Stop Flow
- Start/stop actions never mutate draft state directly.
Startuses the latest persisted config, not unsaved inputs.Save and startis a composed action:save draft -> if success and local mode valid -> start runtime -> refetch live snapshot.Stopaffects live runtime state only, then refetches.
That prevents ambiguous ownership such as starting from a half-edited form that the backend has not persisted yet.
Auxiliary Query Flow
GPU info, model library contents, scanned folders, and Hugging Face search results should be separate queries from the workspace snapshot.
- They are not core runtime status.
- Their failures should not poison runtime readiness state.
- Their data should write into the draft only through explicit user actions like selecting a model or choosing a folder.
Patterns to Follow
Pattern 1: Server state vs client draft split
What: Treat polling data as server/external state and editable fields as client state.
When: Always, for runtime settings forms that stay open while health/status continues changing.
Why: It removes the current overwrite bug at the ownership boundary rather than patching around it with conditional useEffect logic.
Pattern 2: Feature-local state, not app-global store
What: Keep runtime workspace state inside a dedicated feature provider or feature hooks instead of introducing a broad app-wide store.
When: Brownfield restructure limited to runtime workspace.
Why: The existing app does not use a global frontend store. This change is a feature-boundary problem, not a whole-app state-management problem.
Pattern 3: Query/mutation orchestration for Tauri commands
What: Model polling reads as queries and start/stop/save as mutations, even though transport is invoke() instead of HTTP.
When: Any operation that returns asynchronous backend state and needs post-write refresh.
Why: It gives explicit ownership for polling, mutation in-flight state, invalidation, and optimistic UI guards.
Pattern 4: Separate read model from write model
What: Prefer a workspace response shaped like:
type RuntimeWorkspaceResponse = {
live: LiveRuntimeSnapshot;
persistedConfig: PersistedRuntimeConfig;
};
When: As soon as the team touches the Tauri contract.
Why: Nesting writable config inside the polled runtime snapshot is what makes accidental rehydration easy.
Anti-Patterns to Avoid
Anti-Pattern 1: Rehydrating the form from every snapshot refresh
What: useEffect(() => setForm(snapshot.config), [snapshot])
Why bad: Polling becomes an uncontrolled reset mechanism that discards in-progress edits, folder picks, and model selections.
Instead: Initialize draft once per open/reset or once per confirmed baseline revision change.
Anti-Pattern 2: One busy flag for unrelated operations
What: A single boolean covering save, start, stop, scan, and search.
Why bad: It couples unrelated interactions and makes action enablement ambiguous.
Instead: Track mutation states independently: isSaving, isStarting, isStopping, isScanning, isSearchingHub.
Anti-Pattern 3: Letting runtime actions read unsaved form state implicitly
What: Start/stop logic reaching directly into form inputs.
Why bad: Operators cannot tell whether the runtime is using saved config or draft config.
Instead: Runtime operations use persisted config; composed actions explicitly save first.
Scalability Considerations
| Concern | At current scope | As runtime surface grows |
|---|---|---|
| Polling frequency | 3-second polling is acceptable | Move to adaptive polling by state: faster while starting, slower while stopped/remote |
| Mutation contention | Manual guarding is sufficient | Add serialized command queue to prevent overlapping start/stop/save races |
| Contract clarity | Frontend normalization can bridge current shape | Introduce dedicated workspace DTO with live + persistedConfig + revision |
| UI complexity | Feature-local provider is enough | Split drawer into tabs/sections without changing ownership model |
Suggested Build Order And Dependency Implications
-
Normalize the runtime workspace contract
- Introduce frontend selectors/types for
live snapshot,persisted baseline, anddrafteven if the backend still returns the old combined payload. - Dependency implication: everything else should build on this split, not on raw
RuntimeSnapshot.
- Introduce frontend selectors/types for
-
Extract query/mutation hooks from
useRuntimeManager- Create distinct hooks for workspace polling and runtime commands.
- Dependency implication: status panel and action bar can migrate first without touching the form internals.
-
Add the draft provider and migrate
RuntimeSettingsDrawerto controlled draft state- Replace
useEffectrehydration on everysnapshotchange with explicitinitialize/reset/cancelsemantics. - Dependency implication: this is the critical prerequisite for reliable save/cancel behavior.
- Replace
-
Refactor status and actions into read-only/live consumers
RuntimeStatusCardand any future summary header should read from live query only.- Dependency implication: once status no longer reads draft state, polling can remain aggressive without UX regressions.
-
Move auxiliary lookups into separate query slices
- GPU, model library, model scan, and Hub search should become isolated resources.
- Dependency implication: failures and loading states become localized instead of blocking save/start controls.
-
Optionally add backend config revision support
- Persist and return
revisionorupdated_atwith config reads/writes. - Dependency implication: enables stale-draft warnings and cleaner reset rules, but can follow the frontend split if roadmap pressure is high.
- Persist and return
Architecture Recommendation For Roadmap
Phase the work around ownership boundaries, not around visual components:
- State split foundation - separate live polling state from persisted config and draft state
- Workspace action orchestration - isolate save/start/stop/restart-required behavior
- UI restructuring on top of stable ownership - merge status and settings into one control center after the state model is safe
That order matters. If the team starts with UI rearrangement before fixing ownership, the new surface will keep the same overwrite bug under a different layout.
Sources
- Codebase evidence:
tauri-app/src/features/runtime/useRuntimeManager.ts,tauri-app/src/features/runtime/components/RuntimeSettingsDrawer.tsx,tauri-app/src/features/runtime/components/RuntimeStatusCard.tsx,tauri-app/src-tauri/src/runtime/mod.rs,tauri-app/src-tauri/src/runtime/state.rs - React official docs via Context7 (
/reactjs/react.dev) on controlled inputs, preserving/resetting state with keys, and avoiding unnecessary effect-driven derived state - HIGH confidence - TanStack Query official docs via Context7 (
/tanstack/query/v5.90.3) on polling withrefetchInterval, mutation cache updates, and invalidation after writes - HIGH confidence