tauri-plugin-sync-state 0.1.0

Bidirectional, type-keyed state sync between a Tauri backend and a JS/React frontend.
Documentation
# 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

| 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. `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

| 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
  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)