stry.build_version.clone()
}
**Why this works:** `tauri::State<T>` wraps the type in an `Arc<T>` internally. Commands receive a clone of the `Arc`, allowing zero-copy reads. No locking occurs. This pattern is strictly for data that is written once during setup and read frequently thereafter.
### Step 2: Implement Mutable Runtime State with Appropriate Locks
When commands must modify shared data, you must choose a synchronization primitive based on the command's execution model.
For synchronous commands where reads dominate writes, `RwLock` allows concurrent readers while serializing writers.
```rust
use std::sync::RwLock;
use std::time::Instant;
pub struct TaskOrchestrator {
pub active_jobs: Vec<String>,
pub last_checkpoint: Option<Instant>,
pub worker_count: u32,
}
// Registration inside builder chain
.manage(RwLock::new(TaskOrchestrator {
active_jobs: Vec::new(),
last_checkpoint: None,
worker_count: 0,
}))
For async commands, std::sync::Mutex and RwLock will block the Tokio runtime thread if held across .await points. Switch to tokio::sync::Mutex to yield control back to the executor while waiting for lock acquisition.
use tokio::sync::Mutex;
pub struct AsyncWorkerState {
pub is_processing: bool,
pub current_batch_id: Option<String>,
}
// Async command signature
#[tauri::command]
async fn execute_batch(
orchestrator: tauri::State<'_, Mutex<AsyncWorkerState>>,
) -> Result<(), String> {
let mut guard = orchestrator.lock().await;
guard.is_processing = true;
guard.current_batch_id = Some(uuid::Uuid::new_v4().to_string());
drop(guard); // Explicitly release before heavy async work
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
Ok(())
}
Architecture decision: Always prefer tokio::sync::Mutex for commands marked async. The performance overhead of async lock acquisition is negligible compared to the cost of a blocked runtime thread. Reserve std::sync primitives strictly for synchronous command handlers or background threads spawned via std::thread.
Step 3: Bridge Backend Mutations to the Frontend
State changes in Rust are invisible to the JavaScript layer until explicitly pushed. Tauri v2 decouples state storage from state notification. The Emitter trait provides the emit method, but it requires an explicit import.
use tauri::Emitter;
#[tauri::command]
async fn trigger_workflow(
app_handle: tauri::AppHandle,
state: tauri::State<'_, Mutex<AsyncWorkerState>>,
) -> Result<(), String> {
{
let mut guard = state.lock().await;
guard.is_processing = true;
} // Lock released here
// Push notification to all listening webviews
app_handle
.emit("workflow:started", &())
.map_err(|e| e.to_string())?;
Ok(())
}
Frontend subscription requires explicit lifecycle management:
import { listen } from '@tauri-apps/api/event';
export function attachWorkflowListeners() {
const cleanup = listen('workflow:started', () => {
console.log('Backend transitioned to active state');
updateUIIndicator('running');
});
// Return unsubscribe function for component teardown
return async () => {
const unlisten = await cleanup;
unlisten();
};
}
Why this separation matters: Tauri's event system is fire-and-forget. It does not guarantee delivery order under high load, nor does it buffer missed events. Treat events as notifications, not state sources. The frontend should always query the authoritative backend state after receiving an event to resolve race conditions.
Step 4: Persist Critical State Across Sessions
In-memory state vanishes on process termination. For settings, user preferences, or transaction logs, delegate to a disk-backed store. rusqlite provides a lightweight, embedded SQL engine that integrates cleanly with Tauri's setup phase.
use rusqlite::Connection;
use std::sync::Mutex;
pub struct DiskPersistenceLayer {
pub db: Mutex<Connection>,
}
pub fn setup_database(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let db_path = app.path().app_data_dir()?.join("core_store.db");
let conn = Connection::open(db_path)?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);"
)?;
app.manage(DiskPersistenceLayer {
db: Mutex::new(conn),
});
Ok(())
}
#[tauri::command]
fn persist_preference(
key: String,
value: String,
storage: tauri::State<DiskPersistenceLayer>,
) -> Result<(), String> {
let conn = storage.db.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?1, ?2)",
rusqlite::params![key, value],
).map_err(|e| e.to_string())?;
Ok(())
}
Rationale: WAL (Write-Ahead Logging) mode enables concurrent readers while a writer is active, drastically reducing lock contention in desktop apps. Wrapping the connection in Mutex serializes write access safely. This pattern scales to thousands of preferences without blocking the main thread.
Pitfall Guide
1. Async Deadlock via Synchronous Mutex
Explanation: Holding a std::sync::Mutex guard across an .await point blocks the Tokio worker thread. Since Tokio uses a limited thread pool, blocking one thread can starve others, causing the entire application to hang.
Fix: Always use tokio::sync::Mutex for async commands. If you must use std::sync::Mutex, scope the lock to a block { ... } and drop it before any .await.
2. Missing tauri::Emitter Trait
Explanation: In Tauri v2, app.emit() is not available on AppHandle by default. It requires the Emitter trait to be in scope. Forgetting the import results in a confusing compiler error about missing methods.
Fix: Add use tauri::Emitter; at the top of any module that pushes events to the frontend. Consider creating a re-export in your root module to enforce consistency.
3. Frontend Event Subscription Leaks
Explanation: Calling listen() without storing and invoking the returned unlisten function causes memory leaks. Each component mount registers a new listener, but old listeners remain active, firing multiple times per event.
Fix: Always return the cleanup function from your subscription hook and invoke it in the framework's teardown lifecycle (e.g., useEffect cleanup in React, onUnmounted in Vue).
4. Over-Locking Granularity
Explanation: Wrapping an entire state struct in a single Mutex forces unrelated operations to serialize. If a UI refresh command and a heavy file export command share the same lock, the UI will stutter.
Fix: Split state into domain-specific structs. Use separate locks for UI state, I/O state, and configuration. This allows concurrent access to independent subsystems.
5. Mixing Sync and Async Command Contexts
Explanation: Registering a tokio::sync::Mutex and accessing it from a synchronous command causes a compilation error. Conversely, using std::sync::Mutex in an async command risks deadlocks.
Fix: Audit your command signatures. If a command is async, it must only interact with async-safe primitives. If it's sync, stick to std::sync or tauri::State references. Do not mix them in the same state layer.
6. Ignoring State Serialization Boundaries
Explanation: Tauri serializes command arguments and return values via JSON. Complex Rust types with lifetimes, raw pointers, or non-serializable fields will fail at runtime with opaque errors.
Fix: Derive Serialize and Deserialize on all state structs passed across the IPC boundary. Use #[serde(skip)] for fields that should not cross the boundary. Validate serialization in unit tests before deployment.
7. Unbounded In-Memory State Growth
Explanation: Caching API responses or UI history in a Vec without eviction policies eventually exhausts RAM, causing the OS to swap or terminate the process.
Fix: Implement bounded collections. Use VecDeque with a maximum capacity, or integrate a lightweight cache like moka or quick-cache. Evict oldest entries when limits are reached.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| App configuration that never changes | tauri::State<T> | Zero-lock shared references, minimal overhead | Negligible |
| High-read, low-write runtime data | std::sync::RwLock<T> | Allows parallel readers, serializes writers only | Low |
| Async command requiring mutation | tokio::sync::Mutex<T> | Prevents runtime thread starvation, async-safe | Low |
| Sync command requiring mutation | std::sync::Mutex<T> | Lower overhead than async mutex, safe in sync context | Low |
| Settings/preferences surviving restarts | rusqlite with WAL | ACID compliance, concurrent read support, embedded | Medium (disk I/O) |
| Real-time UI updates from backend | app.emit() + frontend listen() | Decouples state storage from notification delivery | Low |
Configuration Template
Copy this baseline into your src-tauri/src/lib.rs to establish a production-ready state layer:
use std::sync::RwLock;
use tauri::Manager;
use tokio::sync::Mutex;
mod state;
mod commands;
pub use state::{AppConfig, RuntimeState, DiskStore};
pub fn run() {
let config = AppConfig::load_default();
let runtime = RuntimeState::default();
tauri::Builder::default()
.setup(|app| {
DiskStore::initialize(app)?;
Ok(())
})
.manage(config)
.manage(RwLock::new(runtime))
.manage(Mutex::new(commands::AsyncWorker::default()))
.invoke_handler(tauri::generate_handler![
commands::fetch_config,
commands::update_runtime,
commands::process_async_task,
commands::save_preference
])
.run(tauri::generate_context!())
.expect("Failed to run Tauri application");
}
Quick Start Guide
- Define your state structs in a dedicated
state.rs module. Derive Clone, Serialize, and Deserialize for all types that cross the IPC boundary.
- Register state in the builder chain using
.manage(). Separate read-only config, mutable runtime state, and async workers into distinct registrations.
- Implement commands that accept
tauri::State<T> parameters. Match lock types to command execution models (tokio::sync::Mutex for async, std::sync for sync).
- Push updates via events by importing
tauri::Emitter and calling app_handle.emit(). Keep payloads minimal; treat events as triggers, not data carriers.
- Initialize disk storage in the
.setup() closure. Configure rusqlite with WAL mode and register the connection wrapper. Query persistent state on app launch to restore session context.