tauri-plugin-sync-state
v0.1.0
Published
Bidirectional, type-keyed state sync between a Tauri backend and a JS/React frontend.
Maintainers
Readme
tauri-plugin-sync-state
Bidirectional, type-keyed state synchronization between a Tauri v2 backend and a JS/React frontend.
- Single source of truth in Rust. State lives in the backend.
- Reactive frontend. A React hook re-renders on every backend change — including changes the backend makes on its own (timers, sockets, tasks).
- Writable from the frontend.
set/patchpush to the backend, which broadcasts the new value back to every window. - Multiple slices, separated by type name — no hand-written string ids.
#[derive(SyncState)]for zero-boilerplate registration.Mutator<S>injects into commands like Tauri's ownState<T>, with field reads viaDerefand sync/async mutation.
How it works
Each state struct is a slice, identified by its type name (AppState →
"AppState"). That name is the registry key, the IPC argument, and the suffix
of a per-slice event channel plugin:sync-state:updated:{Name}, so a listener
for one slice never wakes on another's change.
Rust struct ──register──► StateRegistry (one source of truth)
▲ │ │
│ │ set_state (invoke) │ emit "…updated:{Name}"
│ ▼ ▼
React hook ◄──────────── frontend listener → re-renderInstall
Rust (src-tauri/Cargo.toml)
[dependencies]
tauri-plugin-sync-state = "0.1"
serde = { version = "1", features = ["derive"] }JavaScript
npm install tauri-plugin-sync-state
# or: pnpm add tauri-plugin-sync-statePermissions (src-tauri/capabilities/default.json)
{
"permissions": [
"sync-state:default"
]
}sync-state:default allows get_state and set_state.
Quick start
1. Define and register slices (Rust)
use serde::{Deserialize, Serialize};
use tauri_plugin_sync_state::{Builder, Mutator, SyncState};
#[derive(SyncState, Serialize, Deserialize, Clone, Default)]
struct AppState {
count: i32,
username: String
}
#[derive(SyncState, Serialize, Deserialize, Clone, Default)]
struct UserPrefs {
theme: String,
notifications: bool
}
fn main() {
tauri::Builder::default()
.plugin(
Builder::new()
.register(AppState::default())
.register(UserPrefs::default())
.build(),
)
.invoke_handler(tauri::generate_handler![increment, go_online, whoami])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}2. Read and mutate from commands
Mutator<S> resolves automatically. Reads go through Deref; mutating methods
take &mut self, so mutating commands bind mut.
// read-only — no `mut`
#[tauri::command]
fn whoami(app_state: Mutator<AppState>) -> String {
app_state.username.clone() // Deref field access
}
// mutate — `mut` bindings; both params auto-injected
#[tauri::command]
fn go_online(mut prefs: Mutator<UserPrefs>, mut app_state: Mutator<AppState>) {
prefs.mutate(|p| p.notifications = true);
app_state.mutate(|s| s.username = "Kobe".into());
}
// mix with normal args
#[tauri::command]
fn increment(by: i32, mut app_state: Mutator<AppState>) {
app_state.mutate(|s| s.count += by);
}Async mutation (mutate_async runs your future without holding the lock, then
commits and broadcasts):
#[tauri::command]
async fn sync_username(mut app_state: Mutator<AppState>) {
app_state.mutate_async(|mut s| async move {
s.username = fetch_username_from_server().await;
s
}).await;
}3. Mutate from non-command Rust (timers, sockets, tasks)
use tauri_plugin_sync_state::SyncStateExt;
// anywhere you hold an AppHandle:
app.sync_mutate::<AppState>( | s| s.count += 1).ok(); // updates + broadcasts
let current = app.sync_read::<AppState>() ?; // typed read4. Use it in React
Declare each slice's hook once:
// src/state/index.ts
import { createSyncStateHook } from "tauri-plugin-sync-state/react";
interface AppState {
count: number;
username: string
}
interface UserPrefs {
theme: string;
notifications: boolean
}
export const useAppState = createSyncStateHook<AppState>("AppState");
export const useUserPrefs = createSyncStateHook<UserPrefs>("UserPrefs");Then consume anywhere — it stays in sync with the backend automatically:
import { useAppState, useUserPrefs } from "./state";
function Counter() {
const {state, patch} = useAppState();
if (!state) return null;
return (
<button onClick={() => patch({count: state.count + 1})}>
count: {state.count}
</button>
);
}
function ThemeToggle() {
const {state, patch} = useUserPrefs();
return (
<button onClick={() => patch({theme: state?.theme === "dark" ? "light" : "dark"})}>
theme: {state?.theme ?? "…"}
</button>
);
}The hook hydrates on mount, re-renders on every backend update (including
backend-initiated ones), and set/patch push to the backend optimistically.
API reference
Rust
| Item | Description |
|-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| #[derive(SyncState)] | Implements SyncState, using the type name as NAME. Override with #[sync_state(name = "…")]. Requires Serialize + Deserialize + Clone. |
| Builder::new().register(S::default()).build() | Builds the plugin; register one slice per type. |
| Mutator<S> | Command-injectable handle. Derefs to S for reads. |
| Mutator::mutate(\|s\| …) | Sync mutate + broadcast (logs on error). |
| Mutator::try_mutate(…) | Fallible mutate. |
| Mutator::mutate_async(\|s\| async { … s }).await | Async mutate + broadcast. |
| Mutator::reload() | Re-pull latest into the snapshot (for long commands). |
| AppHandle::sync_read::<S>() / sync_mutate::<S>(…) | Read / mutate from non-command code (SyncStateExt). |
JavaScript
| Import | Description |
|-------------------------------------------|-----------------------------------------|
| getState<S>(name) | Fetch current value. |
| setState<S>(name, value) | Replace value (backend broadcasts). |
| onStateUpdated<S>(name, cb) | Subscribe; returns an unlisten promise. |
| useSyncState<S>(name, fallback?) | Hook → { state, set, patch }. |
| createSyncStateHook<S>(name, fallback?) | Returns a zero-arg bound hook. |
Notes & guarantees
- Identity: the JS string must equal the Rust
NAME. A mismatch produces a clearNotFounderror at runtime, not a silent wrong-type result. - Consistency: one mutex per registry; last-write-wins.
mutate_asyncdoes not hold the lock across.await. - Snapshot semantics: a
Mutator'sDerefvalue is captured at command dispatch and refreshed by its own mutations; callreload()to observe concurrent external writes mid-command. patchis a shallow merge. For nested updates, readstate, build the new object, and callset.- Don't call back into the registry from inside a
mutateclosure (re-entrant lock).
This project is licensed under the MIT License. See the LICENSE file for details.
Agentically created by Claude Opus 4.8
