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")].