All patterns tested across 7 shipped Mac apps. No fluff — just the state management approaches that actually work in production Tauri v2 apps.
The four patterns
| Pattern | Use case | Scope |
|---|---|---|
tauri::State |
App-wide read-only state | Global |
Mutex<T> / RwLock<T>
|
Mutable shared state | Global |
| Event emit | Push state changes to frontend | Global |
| SQLite / file | Persistent state across restarts | Disk |
1. tauri::State — Read-only global state
The simplest pattern. Register once in main.rs, access in any command.
// main.rs
struct AppConfig {
version: String,
data_dir: PathBuf,
}
tauri::Builder::default()
.manage(AppConfig {
version: "1.0.0".to_string(),
data_dir: dirs::data_dir().unwrap(),
})
.invoke_handler(tauri::generate_handler![get_version])
.run(tauri::generate_context!())
.unwrap();
// command
#[tauri::command]
fn get_version(config: tauri::State<AppConfig>) -> String {
config.version.clone()
}
Works well for config and read-only data. Don't reach for Mutex if you don't need mutation.
2. Mutex<T> — Mutable shared state
Which one to use: Start with std::sync::Mutex. If reads heavily outnumber writes, switch to RwLock. If you're in an async command and need to hold the lock across .await, switch to tokio::sync::Mutex.
When multiple commands need to read and write the same data:
use std::sync::Mutex;
struct SyncState {
is_running: bool,
last_synced: Option<std::time::Instant>,
device_id: Option<String>,
}
tauri::Builder::default()
.manage(Mutex::new(SyncState {
is_running: false,
last_synced: None,
device_id: None,
}))
.invoke_handler(tauri::generate_handler![start_sync, get_status])
.run(tauri::generate_context!())
.unwrap();
#[tauri::command]
fn start_sync(state: tauri::State<Mutex<SyncState>>) -> Result<(), String> {
let mut s = state.lock().map_err(|e| e.to_string())?;
s.is_running = true;
Ok(())
}
#[tauri::command]
fn get_status(state: tauri::State<Mutex<SyncState>>) -> bool {
let s = state.lock().unwrap();
s.is_running
}
Tip: Prefer
RwLock<T>when reads are frequent and writes are rare — multiple readers can proceed in parallel.
use std::sync::RwLock;
// Read
let s = state.read().unwrap();
// Write
let mut s = state.write().unwrap();
Async commands:
std::sync::Mutexwill deadlock if you hold the lock across an.await. Usetokio::sync::Mutexinstead for async commands:
use tokio::sync::Mutex;
#[tauri::command]
async fn async_cmd(state: tauri::State<'_, Mutex<MyState>>) -> Result<(), String> {
let mut s = state.lock().await;
some_async_fn().await; // safe — tokio mutex is async-aware
Ok(())
}
3. Emitting state changes to the frontend
State in Rust is invisible to the frontend until you emit it. Use app.emit() to push updates:
use tauri::Emitter; // required in Tauri v2 — easy to forget
#[tauri::command]
async fn start_sync(
app: tauri::AppHandle,
state: tauri::State<'_, Mutex<SyncState>>,
) -> Result<(), String> {
{
let mut s = state.lock().map_err(|e| e.to_string())?;
s.is_running = true;
}
app.emit("sync-started", ()).map_err(|e| e.to_string())?;
Ok(())
}
// Frontend
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('sync-started', () => {
console.log('Sync started');
});
// Cleanup on component unmount
unlisten();
4. Persistent state with SQLite
Everything above is in-memory — it disappears when the app closes. For state that survives restarts, SQLite via rusqlite is the most practical option:
use rusqlite::Connection;
use std::sync::Mutex;
struct Db(Mutex<Connection>);
tauri::Builder::default()
.setup(|app| {
let db_path = app.path().app_data_dir()?.join("app.db");
let conn = Connection::open(db_path)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);"
)?;
app.manage(Db(Mutex::new(conn)));
Ok(())
})
.run(tauri::generate_context!())
.unwrap();
#[tauri::command]
fn save_setting(
key: String,
value: String,
db: tauri::State<Db>,
) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
rusqlite::params![key, value],
).map_err(|e| e.to_string())?;
Ok(())
}
Quick reference
// Register
.manage(MyState { ... })
.manage(Mutex::new(MyState { ... })) // std — sync only
.manage(tokio::sync::Mutex::new(MyState { ... })) // tokio — async safe
// Access in command
fn my_cmd(state: tauri::State<MyState>) { ... }
fn my_cmd(state: tauri::State<Mutex<MyState>>) {
let s = state.lock().unwrap(); // std
}
async fn my_cmd(state: tauri::State<'_, tokio::sync::Mutex<MyState>>) {
let s = state.lock().await; // tokio
}
// Emit to frontend (requires `use tauri::Emitter;`)
app.emit("event-name", payload)?;
// Listen on frontend
await listen('event-name', (e) => { ... });
Common mistakes
Holding a std::sync::Mutex lock across .await — deadlock guaranteed. Either release the lock first, or switch to tokio::sync::Mutex:
// ❌ Deadlock
async fn bad(state: tauri::State<'_, std::sync::Mutex<MyState>>) {
let s = state.lock().unwrap();
some_async_fn().await; // lock held here
}
// ✅ Release first
async fn good(state: tauri::State<'_, std::sync::Mutex<MyState>>) {
{
let mut s = state.lock().unwrap();
s.value = 42;
} // lock dropped
some_async_fn().await;
}
// ✅ Or use tokio::sync::Mutex
async fn also_good(state: tauri::State<'_, tokio::sync::Mutex<MyState>>) {
let mut s = state.lock().await;
some_async_fn().await; // safe
}
Forgetting use tauri::Emitter; — app.emit() silently fails to compile without it in Tauri v2. Easy to miss when copying examples from v1 docs.
These patterns are all running in production across the Hiyoko app lineup — HiyokoKit uses most of them in a single codebase if you want a real-world reference.
HiyokoKit → https://hiyokomtp.lemonsqueezy.com/checkout/buy/2c94dd0f-e28a-4a17-8efc-7bd93087d46d
X → @hiyoyok