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)

[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-state

Permissions (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 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 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 file for details.

Agentically created by Claude Opus 4.8