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-render
Install
Rust (src-tauri/Cargo.toml)
[]
= "0.1"
= { = "1", = ["derive"] }
JavaScript
# or: pnpm add tauri-plugin-sync-state
Permissions (src-tauri/capabilities/default.json)
sync-state:default allows get_state and set_state.
Quick start
1. Define and register slices (Rust)
use ;
use ;
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`
// mutate — `mut` bindings; both params auto-injected
// mix with normal args
Async mutation (mutate_async runs your future without holding the lock, then
commits and broadcasts):
async
3. Mutate from non-command Rust (timers, sockets, tasks)
use SyncStateExt;
// anywhere you hold an AppHandle:
app..ok; // updates + broadcasts
let current = app. ?; // typed read
4. 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