# 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`/`patch` push 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 own `State<T>`, with field
reads via `Deref` and 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`)
```toml
[dependencies]
tauri-plugin-sync-state = "0.1"
serde = { version = "1", features = ["derive"] }
```
### JavaScript
```bash
npm install tauri-plugin-sync-state
# or: pnpm add tauri-plugin-sync-state
```
### Permissions (`src-tauri/capabilities/default.json`)
```json
{
"permissions": [
"sync-state:default"
]
}
```
`sync-state:default` allows `get_state` and `set_state`.
---
## Quick start
### 1. Define and register slices (Rust)
```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`.
```rust
// 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):
```rust
#[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)
```rust
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 read
```
### 4. Use it in React
Declare each slice's hook once:
```ts
// 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:
```tsx
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
| `#[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. `Deref`s 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
| `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
clear `NotFound` error at runtime, not a silent wrong-type result.
- **Consistency:** one mutex per registry; **last-write-wins**. `mutate_async`
does not hold the lock across `.await`.
- **Snapshot semantics:** a `Mutator`'s `Deref` value is captured at command
dispatch and refreshed by its own mutations; call `reload()` to observe
concurrent external writes mid-command.
- **`patch` is a shallow merge.** For nested updates, read `state`, build the
new object, and call `set`.
- Don't call back into the registry from inside a `mutate` closure (re-entrant
lock).
---
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
Agentically created by [Claude Opus 4.8](https://claude.com/product/overview)