How to Manage State in Tauri Applications Between Frontend and Backend
Tauri enables seamless state sharing between your JavaScript frontend and Rust backend through the State<T> wrapper and Builder::manage API, which stores type-registered data in a global StateManager accessible from any command.
Managing shared state across the frontend-backend boundary is essential for building complex desktop applications with Tauri. The tauri-apps/tauri repository provides a type-safe, thread-safe solution that leverages Rust's ownership model to prevent data races while allowing JavaScript to invoke Rust functions that operate on persistent data. This guide explains how to implement state management using the official APIs defined in crates/tauri/src/state.rs and crates/tauri/src/app.rs.
Understanding the State Architecture
Tauri's state management centers on the State<'r, T> guard and the StateManager struct. When you register a state value using Builder::manage (lines 1794-1804 in crates/tauri/src/app.rs), the system stores your data in a HashMap<TypeId, Pin<Box<dyn Any + Send + Sync>>> that maps concrete Rust types to their instances. This design guarantees a single source of truth per type—attempting to register the same type twice triggers a panic, preventing accidental state duplication.
Thread Safety and Concurrency Guarantees
Because the underlying StateManager must safely handle concurrent access from multiple commands, your state type must implement Send + Sync. The manager's internal map is protected by a Mutex, and the State<T> wrapper implements Deref to provide direct access to the inner value. When a command is invoked, State::from_command (lines 60-68 in crates/tauri/src/state.rs) extracts the state from the manager; if the type was not previously registered, it returns an InvokeError rather than panicking.
Lifetime and Storage Model
State registered via .manage() lives for the application's entire lifetime in a static-like container. The State<'r, T> guard carries a lifetime matching the command's request scope, ensuring you cannot leak references outside the command, while the underlying data persists across multiple frontend invocations until the application terminates.
Registering and Accessing Application State
Step 1: Register State with manage()
Register your state container during application initialization:
use std::sync::Mutex;
use tauri::State;
struct Counter(Mutex<isize>);
fn main() {
tauri::Builder::default()
.manage(Counter(Mutex::new(0))) // Registers with StateManager
.invoke_handler(tauri::generate_handler![increment, get])
.run(tauri::generate_context!())
.expect("failed to run app");
}
The manage method calls StateManager::set internally, inserting your value into the global map using its TypeId as the key.
Step 2: Access State in Commands
Retrieve registered state by adding State<'_, T> as a function parameter:
#[tauri::command]
fn increment(counter: State<'_, Counter>) -> isize {
let mut c = counter.0.lock().unwrap();
*c += 1;
*c
}
#[tauri::command]
fn get(counter: State<'_, Counter>) -> isize {
*counter.0.lock().unwrap()
}
The runtime automatically injects the correct instance when the frontend invokes these commands via the Tauri API.
Step 3: Access State from Non-Command Contexts
For background threads or setup hooks, use the Manager trait's state() method on AppHandle:
use tauri::Manager;
fn spawn_worker<R: tauri::Runtime>(handle: tauri::AppHandle<R>) {
std::thread::spawn(move || {
let state: State<'_, Counter> = handle.state();
// Access state here
});
}
This approach leverages the same StateManager but allows access from anywhere you hold an AppHandle.
Practical Implementation Examples
Example 1: Thread-Safe Counter
The official example demonstrates managing mutable state with a Mutex:
// src-tauri/src/main.rs
use std::sync::Mutex;
use tauri::State;
struct Counter(Mutex<isize>);
#[tauri::command]
fn increment(counter: State<'_, Counter>) -> isize {
let mut c = counter.0.lock().unwrap();
*c += 1;
*c
}
#[tauri::command]
fn get(counter: State<'_, Counter>) -> isize {
*counter.0.lock().unwrap()
}
fn main() {
tauri::Builder::default()
.manage(Counter(Mutex::new(0)))
.invoke_handler(tauri::generate_handler![increment, get])
.run(tauri::generate_context!())
.expect("failed to run app");
}
Invoke from your frontend JavaScript:
import { invoke } from '@tauri-apps/api/tauri';
async function increase() {
const value = await invoke('increment');
console.log('Counter is now', value);
}
Example 2: Database Connection Management
For SQLite or other database connections, register the connection handle directly (ensure thread-safety via connection pooling or the shared feature for SQLite):
use tauri::State;
use rusqlite::Connection;
struct DbConnection {
conn: Connection,
}
#[tauri::command]
fn list_users(db: State<'_, DbConnection>) -> Result<Vec<String>, String> {
let mut stmt = db.conn.prepare("SELECT name FROM users")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.collect()
}
#[tauri::command]
fn add_user(name: String, db: State<'_, DbConnection>) -> Result<(), String> {
db.conn
.execute("INSERT INTO users (name) VALUES (?1)", [&name])
.map_err(|e| e.to_string())?;
Ok(())
}
fn main() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
[],
).unwrap();
tauri::Builder::default()
.manage(DbConnection { conn })
.invoke_handler(tauri::generate_handler![list_users, add_user])
.run(tauri::generate_context!())
.expect("run error");
}
Example 3: Background Thread State Access
Access global state from spawned threads using AppHandle::state():
use std::thread;
use tauri::{Manager, State};
struct SharedData(Mutex<Vec<String>>);
fn spawn_background_task<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
thread::spawn(move || {
let state: State<'_, SharedData> = app_handle.state();
let mut data = state.0.lock().unwrap();
data.push("background entry".into());
});
}
fn main() {
tauri::Builder::default()
.manage(SharedData(Mutex::new(vec![])))
.setup(|app| {
spawn_background_task(app.handle().clone());
Ok(())
})
.run(tauri::generate_context!())
.expect("run error");
}
Key Implementation Files in the Tauri Source
| File | Purpose | Key Components |
|---|---|---|
crates/tauri/src/state.rs |
State guard and manager | State<'r, T>, StateManager, State::from_command |
crates/tauri/src/app.rs (lines 1794-1804) |
State registration | Builder::manage, StateManager::set |
crates/tauri/src/lib.rs |
Manager trait | Manager::state<T>() for AppHandle access |
examples/state/main.rs |
Reference implementation | Complete working counter example |
Summary
- Type-safe registration: Use
Builder::manageincrates/tauri/src/app.rsto register exactly one instance per concrete type, stored in aHashMap<TypeId>withinStateManager. - Command injection: Access state via
State<'_, T>parameters; the runtime extracts values usingState::from_commandand returnsInvokeErrorfor unregistered types. - Thread safety: Wrap mutable data in
MutexorRwLockto satisfySend + Syncbounds required by Tauri's multi-threaded command runtime. - Global access: Use
AppHandle::state()from theManagertrait to access state in background threads, setup hooks, or other non-command contexts. - Lifetime safety: State persists for the application's
'staticlifetime but cannot be leaked outside command scope due to the'rlifetime constraint onState<'r, T>.
Frequently Asked Questions
How does Tauri prevent duplicate state registrations?
The StateManager internally uses a HashMap<TypeId, Pin<Box<dyn Any + Send + Sync>>> where each type can only occupy one entry. When Builder::manage calls StateManager::set, the system checks for existing entries and panics if the type has already been registered, enforcing a single source of truth per concrete type according to the implementation in crates/tauri/src/app.rs.
Can I use State with async commands?
Yes. The State<'_, T> extractor works with both synchronous and asynchronous command handlers because the underlying data lives for the 'static lifetime of the application. Ensure your state type implements Send if it will be held across await points in multi-threaded runtimes, matching the Send + Sync bounds required by StateManager.
What happens if I request a state type that wasn't registered?
When a command requests State<'_, T> for an unregistered type T, the State::from_command method (lines 60-68 in crates/tauri/src/state.rs) fails to find the type in the manager's map and returns an InvokeError. This error propagates to the frontend as a rejected promise, allowing graceful error handling rather than causing a runtime panic.
Is State suitable for per-window data?
No. State<T> is global to the entire application instance and managed by the single StateManager. For per-window or per-webview state, use the Window or Webview APIs provided by Tauri, which allow attaching data specific to individual windows. The State system is designed specifically for shared application-wide resources like database connections, configuration caches, or global counters.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →