Tauri Commands Tutorial: Frontend to Backend Communication with Rust & JavaScript
Video: Tauri Commands Tutorial: Frontend to Backend Communication with Rust & JavaScript by Taught by Celeste AI - AI Coding Coach
#[tauri::command]on a Rust function plusinvoke('name', { args })from JavaScript. The two-piece bridge between React and Rust.
A Tauri app's superpower is calling Rust from the frontend. Filesystem, network, OS APIs — anything Rust can do, you can expose to the React side as a command and call it like a normal async function. This lesson is the full picture.
The basic pattern
Rust side:
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
Register it in your Builder:
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Frontend side:
import { invoke } from "@tauri-apps/api/core";
const greeting = await invoke<string>("greet", { name: "Alice" });
console.log(greeting); // "Hello, Alice!"
That is the whole flow. invoke('command_name', argsObject) returns a Promise that resolves to whatever the Rust function returns.
#[tauri::command]
#[tauri::command]
fn greet(name: &str) -> String { ... }
The #[tauri::command] attribute macro registers the function with Tauri's command system. The function can take any number of arguments (so long as their types implement serde::Deserialize) and return anything serde::Serialize.
Common argument types: String, &str, i32 / i64 / f64, bool, Vec<T>, HashMap<K, V>, custom structs with #[derive(Deserialize)]. The serialisation format is JSON over the bridge.
Common return types: same. Plus Result<T, E> for fallible commands, with E: Serialize so errors can travel to the frontend.
generate_handler!
.invoke_handler(tauri::generate_handler![greet, save_note, load_note])
The generate_handler! macro builds a router that dispatches by command name. Add each command function name to the list. Forget one and invoke('its_name', ...) returns an error from the frontend.
Argument naming
#[tauri::command]
fn save_note(title: String, body: String) -> Result<(), String> { ... }
await invoke("save_note", { title: "Idea", body: "..." });
The argument names in the Rust function and the keys in the JS object must match. By convention Rust uses snake_case (save_note) and JavaScript uses camelCase. Tauri converts between them automatically — tauri::command accepts the JS-side saveNote and the Rust-side save_note. For arguments inside the object, Tauri 2 expects camelCase from JS (startDate) which maps to snake_case (start_date) in Rust.
Returning Results for fallible commands
#[tauri::command]
fn save_note(path: String, content: String) -> Result<(), String> {
std::fs::write(&path, &content).map_err(|e| e.to_string())
}
When a command can fail, return a Result<T, E>. On success, the JS-side promise resolves to T. On error, the promise rejects with E.
Frontend usage:
try {
await invoke("save_note", { path, content });
// success
} catch (err) {
console.error("Save failed:", err);
}
The E type can be anything serialisable — a string is simplest, but a struct gives the frontend more information to handle. We'll get into custom error types in a later lesson.
Async commands
#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
reqwest::get(&url)
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())
}
Add async to the function signature. Tauri runs async commands on a Tokio worker without blocking the main thread. From the frontend, invoke() works the same — it returns a promise that resolves when the Rust future completes.
For long-running operations (network requests, large file reads), use async commands. For trivial work, the synchronous version is fine.
Typing on the frontend
import { invoke } from "@tauri-apps/api/core";
interface Note {
id: number;
title: string;
body: string;
}
const notes = await invoke<Note[]>("list_notes");
invoke<T>() lets you specify the expected return type. TypeScript can't verify that the Rust side actually returns this shape, but the type annotation is at least documentation and gives you autocomplete.
For full type safety, generate types from the Rust definitions with a tool like specta or ts-rs. Both produce TypeScript declarations from your Rust types so the contract stays in sync.
The invoke handler is per-command
.invoke_handler(tauri::generate_handler![greet, save_note, load_note, list_notes])
Every command must be in this list. Forgetting one means the frontend gets a "command not found" error. For larger apps, organise commands into modules:
mod notes;
mod settings;
.invoke_handler(tauri::generate_handler![
notes::create,
notes::list,
notes::update,
notes::delete,
settings::get,
settings::set,
])
The module path is part of the command name in the macro, but the JS-side name is just the function name (e.g. invoke('create')).
Command state
A common pattern: pass shared state to a command.
struct AppState {
db: Mutex<Connection>,
}
#[tauri::command]
fn add_note(state: tauri::State<AppState>, title: String) -> Result<(), String> {
let db = state.db.lock().unwrap();
// use db
Ok(())
}
// In Builder:
.manage(AppState { db: Mutex::new(connection) })
tauri::State<T> is a magic argument type — Tauri injects the managed state when the command is called. The frontend doesn't know about state; it just calls invoke('add_note', { title }) and the state is provided automatically.
.manage(state) registers the state with the Builder so it can be injected.
Common mistakes
Forgetting generate_handler!. Adding #[tauri::command] doesn't register the function. You also need it in the generate_handler! list.
Mismatched argument names. Frontend sends { user_name: "x" } but Rust expects name. Frontend should send { name: "x" }. If you mean the same Rust parameter, both names should match (camelCase JS, snake_case Rust).
Missing Deserialize on a custom struct argument. Without it, Tauri can't deserialise the argument. Derive Deserialize on the struct.
Blocking the main thread on long sync commands. Use async for I/O. The synchronous form blocks the runtime briefly; for anything over a few ms, prefer async.
Forgetting capabilities. In Tauri 2, plugin commands need permission via the capability file. Custom commands defined in your own lib.rs don't need explicit ACL by default, but plugin commands do.
What's next
Next lesson: events. Commands are request-response. Events are fire-and-forget — the backend can push messages to the frontend (e.g., progress updates, real-time notifications) without the frontend asking. Bidirectional communication.
Recap
#[tauri::command] on Rust functions plus tauri::generate_handler![name1, name2] to register them. From JS, invoke<T>('name', { args }) returns a Promise<T>. Async commands run on a worker. Use Result<T, E> for fallible commands. Use tauri::State<T> for shared state. Generate TypeScript types with specta or ts-rs for full type safety.
Next lesson: events. See you in the next one.