Tauri + React — the stack this site uses (and why)

· Builds

4 desktop apps shipped in Tauri+React. The setup, state management, IPC bridge, and gotchas I hit on every project. Field-tested, not theoretical.

4 desktop apps shipped in Tauri+React. Here's the setup, the state management pattern, the IPC bridge, and the gotchas I hit on every project. If you're picking a Tauri stack, this is the field-tested version.

Why Tauri+React over the alternatives

The decision came down to: React has the biggest component ecosystem (shadcn/ui alone is worth it), the most learning material, and any dev can pick it up. Tauri+React gives me Rust safety on the backend with React velocity on the frontend.

The setup

Scaffold:

Add shadcn/ui for components:

Project structure:

The IPC bridge — typed on both sides

This is the most important pattern in Tauri+React. The IPC bridge is the boundary between Rust and JS — it should be typed on both sides, with shared types where possible.

Rust side:

#[derive(Serialize, Deserialize)] pub struct Todo { pub id: i64, pub title: String, pub done: bool, }

#[tauri::command] pub fn gettodos(state: State<AppState) - Result<Vec<Todo, String { let conn = state.db.lock().maperr(|e| e.tostring())?; let mut stmt = conn.prepare("SELECT id, title, done FROM todos") .maperr(|e| e.tostring())?; let todos = stmt.querymap([], |row| { Ok(Todo { id: row.get(0)?, title: row.get(1)?, done: row.get(2)?, }) }).maperr(|e| e.tostring())? .collect::<Result<Vec<, () .maperr(|e| e.tostring())?; Ok(todos) }

TypeScript side — typed wrapper:

export type Todo = { id: number; title: string; done: boolean; };

export async function getTodos(): Promise<Todo[] { return await invoke<Todo[]('gettodos'); }

export async function addTodo(title: string): Promise<Todo { return await invoke<Todo('addtodo', { title }); }

Now in your React components, you call typed functions — no string-based invoke, no manual casting:

export function TodoList() { const [todos, setTodos] = useState<Todo[]([]);

useEffect(() = { getTodos().then(setTodos).catch(console.error); }, []);

return ( <ul {todos.map(t = <li key={t.id}{t.title}</li)} </ul ); }

The type sync is manual — change the Rust struct, change the TS type. There are crates that auto-generate TS types from Rust (ts-rs), but for small apps the manual sync is fine and keeps the build simple.

State management — keep it boring

I've tried Redux, Zustand, Jotai, Valtio, plain React context. For Tauri apps, Zustand is the sweet spot:

type Store = { todos: Todo[]; loading: boolean; loadTodos: () = Promise<void; };

export const useStore = create<Store((set) = ({ todos: [], loading: false, loadTodos: async () = { set({ loading: true }); const todos = await getTodos(); set({ todos, loading: false }); }, }));

Why Zustand: no provider boilerplate, no reducers, works outside React (useful for calling from Tauri event listeners).

Common gotchas

Gotcha 1 — invoke arguments use snakecase

If your TS uses camelCase and Rust uses snakecase, calls silently fail with "missing argument." Either pick a convention or use #[tauri::command(renameall = "camelCase")].

Gotcha 2 — Don't block the main thread in Rust