C.W.K.
Stream
Lesson 03 of 06 · published

Shared Mutable State (Mutex/RwLock)

~14 min · tauri, mutex, concurrency, rust

Level 0Web Tourist
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Many commands, one piece of memory, possibly at the same time. The lock is what stops them from stepping on each other."

Why You Can't Just Mutate

Managed state hands you a shared reference, and Rust forbids mutating through a shared reference — because two commands could run concurrently and corrupt the data. The safe way to allow mutation is interior mutability: wrap the changeable data in a Mutex<T>. To change it, you lock() the mutex, which gives you exclusive access until the lock guard drops. Any other command that tries to lock waits its turn. No torn writes, no races — enforced.

Lock, Mutate, Release — Fast

Hold the lock for as little time as possible. Lock it, make your change, let the guard drop (often just by ending the scope), and you're done. A lock held too long serializes your whole app on that one mutex. For state that's read far more than written, a RwLock lets many readers in at once and only blocks for writes — a better fit for caches and config that occasionally updates.

The Async Trap (Again)

Remember the Send rule from the bridge track: a std::sync::Mutex guard must not be held across an .await — it won't compile in an async command, and even if it did, it would risk deadlock. Either drop the guard before awaiting (lock, copy what you need, unlock, then await) or use an async-aware mutex like tokio::sync::Mutex. For most commands, the lock-copy-unlock pattern is simplest and fastest.

Code

A counter that survives across commands·rust
use std::sync::Mutex;

// Mutable shared state: the counter is wrapped in a Mutex.
#[derive(Default)]
struct Counter {
    value: Mutex<i64>,
}

#[tauri::command]
fn increment(state: tauri::State<Counter>) -> i64 {
    // Lock, mutate, and return — the guard drops at the end of the line/scope.
    let mut v = state.value.lock().unwrap();
    *v += 1;
    *v
}

pub fn run() {
    tauri::Builder::default()
        .manage(Counter::default())
        .invoke_handler(tauri::generate_handler![increment])
        .run(tauri::generate_context!())
        .expect("error");
}
Lock, copy, unlock — then await·rust
// Async-safe pattern: copy out under the lock, THEN await. Never hold
// a std Mutex guard across .await.
#[tauri::command]
async fn save_then_report(state: tauri::State<'_, Counter>) -> Result<i64, String> {
    let snapshot = { *state.value.lock().unwrap() }; // lock drops here
    do_async_io(snapshot).await.map_err(|e| e.to_string())?;
    Ok(snapshot)
}

External links

Exercise

Build a counter: managed state with a Mutex<i64>, plus increment and get_count commands. Wire two frontend buttons and confirm the count persists and increases across clicks (and across component re-renders) because it lives in the Rust core, not React state. Bonus: explain in one line why removing the Mutex would make the code fail to compile.
Hint
increment: lock, *v += 1, return *v. The Mutex is required because tauri::State is a shared reference and Rust won't let you mutate through it without interior mutability. Without the Mutex, *v += 1 on a shared &i64 is a compile error.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.