Back to Blog

How to Share State Between Rust and React in Tauri

Sandy LaneSandy Lane

Video: How to Share State Between Rust and React in Tauri by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Three patterns: managed state via tauri::State<T>, events for change broadcasts, commands for explicit reads. Pick the right one per use case.

A Tauri app has two state stores: the React state in the frontend and the Rust state in the backend. Keeping them coordinated is the central architectural problem.

This lesson covers the three patterns that work and when to use each.

Pattern 1: Managed state in Rust

Tauri's manage API lets you stash state in the Builder and inject it into commands:

use std::sync::Mutex;

#[derive(Default)]
struct AppState {
  counter: Mutex<i32>,
  user_name: Mutex<Option<String>>,
}

#[tauri::command]
fn increment(state: tauri::State<AppState>) -> i32 {
  let mut count = state.counter.lock().unwrap();
  *count += 1;
  *count
}

#[tauri::command]
fn get_count(state: tauri::State<AppState>) -> i32 {
  *state.counter.lock().unwrap()
}

#[tauri::command]
fn set_user(state: tauri::State<AppState>, name: String) {
  *state.user_name.lock().unwrap() = Some(name);
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
  tauri::Builder::default()
    .manage(AppState::default())
    .invoke_handler(tauri::generate_handler![increment, get_count, set_user])
    // ...
}

The state lives for the app's lifetime. tauri::State<AppState> is a magic argument type — Tauri injects the managed state when the command is called. The frontend doesn't know about state injection; it just calls invoke('increment').

Use this for:

  • Database connection pools.
  • Cached data that's expensive to recompute.
  • Configuration loaded at startup.
  • Synchronisation primitives (shared queues, semaphores).

Mutex vs RwLock vs Tokio's async Mutex

For sync state (read and write quickly):

state: Mutex<i32>

For mostly-read state with occasional writes:

state: std::sync::RwLock<HashMap<String, String>>

Multiple readers can hold the lock simultaneously; writers are exclusive.

For state accessed from async commands:

state: tokio::sync::Mutex<sqlx::SqlitePool>

Tokio's Mutex is async-aware — lock().await doesn't block the runtime thread. Use it when the lock is held across .await points.

Pattern 2: React Context for frontend state

Inside the frontend, a single source of truth helps avoid prop drilling. React Context plus useState or a state library (Zustand, Jotai, Redux) handles the frontend.

import { createContext, useContext, useState, useEffect } from "react";

interface AppState {
  user: User | null;
  setUser: (u: User | null) => void;
}

const Ctx = createContext<AppState | null>(null);

export function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    invoke<User | null>("get_user").then(setUser);
  }, []);

  return <Ctx.Provider value={{ user, setUser }}>{children}</Ctx.Provider>;
}

export function useAppState() {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error("useAppState outside provider");
  return ctx;
}

Inside any component:

const { user, setUser } = useAppState();

Pure React. The Tauri side doesn't know about Context.

Pattern 3: Events for backend → frontend updates

When the backend changes state on its own (file watcher, scheduled job, push notification), the frontend won't know unless told.

Use events:

// in some background task
app.emit("user-updated", new_user_data).ok();
useEffect(() => {
  const unlisten = listen<User>("user-updated", (e) => setUser(e.payload));
  return () => { unlisten.then(fn => fn()); };
}, []);

The frontend updates its state in response.

When to choose which

Pattern Use when
Managed state in Rust The data is expensive to recompute, shared across commands, or held by Rust libraries (DB pool, websocket connections).
React Context The data is purely UI-side: open dialogs, currently-selected items, transient state.
Events The backend has news at unpredictable times: file changed, sync complete, notification arrived.

A real app uses all three. Database connection lives in Rust state. The currently-selected article ID lives in React state. "Article updated by another window" comes through as an event.

Synchronising the two

The tricky case: state that should be the same in both places. A user's profile, the current settings, the list of items.

The pattern:

  1. Source of truth on the Rust side. The DB or a Rust struct.
  2. Frontend mirror in React state. Initialised by calling a "get" command.
  3. Mutations call commands. Those commands update the Rust source of truth.
  4. After mutation, refresh. Either re-fetch with the get command, or have the command return the new state.
  5. Multi-window apps emit events. Other windows refresh on the event.

Concrete example for settings:

#[tauri::command]
fn get_settings(state: tauri::State<AppState>) -> Settings {
  state.settings.lock().unwrap().clone()
}

#[tauri::command]
fn save_settings(
  state: tauri::State<AppState>,
  app: AppHandle,
  new: Settings,
) -> Result<(), String> {
  *state.settings.lock().unwrap() = new.clone();
  // persist to disk, etc.
  app.emit("settings-changed", new).ok();
  Ok(())
}
const [settings, setSettings] = useState<Settings | null>(null);

useEffect(() => {
  invoke<Settings>("get_settings").then(setSettings);
  const unlisten = listen<Settings>("settings-changed", (e) => setSettings(e.payload));
  return () => { unlisten.then(fn => fn()); };
}, []);

async function update(s: Settings) {
  await invoke("save_settings", { new: s });
  // setSettings happens automatically via the event listener
}

The event listener handles both same-window and cross-window updates — saving fires the event, every listener (including the one in the same window) updates.

Avoid double-state

A common mistake is keeping a copy of Rust state in React without a refresh path:

const [user, setUser] = useState({ name: "Alice", age: 30 });

async function updateAge(newAge: number) {
  await invoke("set_age", { age: newAge });
  setUser({ ...user, age: newAge });    // mirror manually
}

This works until something else changes the Rust side. Now your React state is stale and you don't know it.

Better: read from Rust after every mutation, or use events. The pattern is Rust is the source of truth, React mirrors.

Commands that return the new state

Often the simplest:

#[tauri::command]
fn set_age(state: tauri::State<AppState>, age: u32) -> User {
  let mut user = state.user.lock().unwrap();
  user.age = age;
  user.clone()
}
const newUser = await invoke<User>("set_age", { age: 31 });
setUser(newUser);

The command mutates and returns the new state. The frontend updates atomically.

Common mistakes

Holding a std::sync::Mutex across .await. The async task can be moved between threads while holding the lock; std::sync::Mutex doesn't support that. Use tokio::sync::Mutex.

Forgetting Default. manage(AppState::default()) requires the type implement Default. Either derive it or initialise explicitly: manage(AppState { ... }).

Mirroring complex state by hand. For deeply nested state, manual mirroring goes wrong. Re-fetch or use events.

Two writers. If both React and Rust mutate independently, conflicts are inevitable. Pick one source of truth.

Stale React closures. A listen callback that uses React state captures the initial state. Use useRef or include state in the useEffect dependency list.

What's next

Next lesson: using Rust crates in Tauri. Pulling in regex, chrono, uuid, and other crates from crates.io to extend your backend.

Recap

Three patterns: managed state via tauri::State<T> for Rust-side; React Context for UI-side; events for backend-initiated updates. For state that should be the same in both halves, Rust is the source of truth and React mirrors. Use Tokio's async Mutex when locks are held across .await. Return the new state from mutation commands or fire events for cross-window sync.

Next: using Rust crates.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.