Tauri v2 State Management: Patterns From 7 Shipped Apps

rust dev.to

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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Async commands: std::sync::Mutex will deadlock if you hold the lock across an .await. Use tokio::sync::Mutex instead 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(())
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode
// Frontend
import { listen } from '@tauri-apps/api/event';

const unlisten = await listen('sync-started', () => {
  console.log('Sync started');
});

// Cleanup on component unmount
unlisten();
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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) => { ... });
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Source: dev.to

arrow_back Back to Tutorials