Calling Rust from React
How to invoke Rust commands from the React frontend in a Tauri v2 application, including passing arguments, receiving return values, and handling errors.
Tauri provides a type-safe command system that lets you call Rust functions directly from your React frontend. The bridge is the invoke function, which sends a command name and optional arguments to the Rust backend over IPC and returns a JavaScript Promise. The serialization and deserialization happen automatically, so on the React side you work with standard JavaScript objects and on the Rust side you work with native types.
Importing the invoke Function
The recommended way is to import invoke from the @tauri-apps/api npm package. This gives you a fully typed API and works with modern bundlers like Vite.
If you have not yet installed the API package, add it to your frontend dependencies:
npm install @tauri-apps/api
Then in any React component or module you can import and use invoke:
import { invoke } from "@tauri-apps/api/core";
Alternative: Global Tauri Script:
Tauri also exposes invoke through the global window.__TAURI__ object. This approach does not require the npm package but you must enable it in tauri.conf.json by setting app.withGlobalTauri to true. Use this only if you have a specific reason to avoid the package, because it loses TypeScript definitions.
import { invoke } from "@tauri-apps/api/core";
Calling a Basic Command
A Tauri command is a Rust function annotated with #[tauri::command] and registered with the builder. Once that is done, React calls it by name with invoke. The command name is a string and must match exactly, including casing.
Here is a Rust command that returns a greeting:
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
On the React side you invoke it and await the result. The following component calls greet when a button is clicked and displays the result in state:
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
const [message, setMessage] = useState("");
async function handleGreet() {
const result = await invoke<string>("greet", { name: "React" });
setMessage(result);
}
return (
<div>
<button onClick={handleGreet}>Greet from Rust</button>
{message && <p>{message}</p>}
</div>
);
}
export default App;
The invoke call returns a Promise that resolves with the value Rust returns. You must await it (or use .then). The generic type parameter invoke<string> tells TypeScript what the return type is so you get proper typing.
If you see the greeting:
When the button is pressed and no errors appear, the message “Hello, React!” is displayed. This confirms the Rust command was registered correctly and the IPC round‑trip is working.
Passing Arguments
Arguments are sent as a plain JavaScript object. Tauri automatically converts the object’s camelCase keys to the snake_case parameter names used in Rust. Every field in the object must match a parameter of the command, and the types must be compatible with serde::Deserialize.
The Rust command can accept numbers, booleans, strings, structs, and more. Here the command expects a user object:
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
name: String,
age: u8,
}
#[tauri::command]
fn register_user(user: User) -> String {
format!("Registered {} who is {} years old", user.name, user.age)
}
In React you pass the corresponding object with camelCase keys:
const message = await invoke<string>("register_user", {
user: { name: "Alice", age: 30 },
});
Even though the Rust parameter is age, the JavaScript key is age because it is already camelCase – Tauri’s name conversion only translates snake_case to camelCase for top‑level parameter names when you have a single primitive argument. For structured arguments, you control the shape with #[serde(rename_all = "camelCase")] if needed. In this example, the field age is already camelCase so it maps directly.
Incorrect casing will cause a runtime error:
If you send a key age but the Rust field is age, it works. However, if you accidentally use age when the Rust field is age (which is already camelCase) you'll get a deserialization error. Always match the JavaScript key to the Rust field name exactly, or use #[serde(rename = "name")] to control mapping explicitly.
Receiving Returned Data
Any Rust return type that implements serde::Serialize can be received in React. The invoke promise resolves with the serialized value. You can destructure it or assign it to a typed variable.
#[tauri::command]
fn get_count() -> u32 {
42
}
const count = await invoke<number>("get_count");
console.log(count); // 42
When you return a complex type, the object is available directly:
use serde::Serialize;
#[derive(Serialize)]
struct Settings {
theme: String,
notifications: bool,
}
#[tauri::command]
fn load_settings() -> Settings {
Settings {
theme: "dark".into(),
notifications: true,
}
}
interface Settings {
theme: string;
notifications: boolean;
}
const settings = await invoke<Settings>("load_settings");
console.log(settings.theme); // "dark"
The promise resolves with the exact shape, no additional parsing required.
Handling Errors
If a Rust command returns a Result::Err, the promise rejects. The error value is also serialized, so you can catch it and display meaningful information to the user. In the Rust code you can return a Result<T, E> where both T and E implement serde::Serialize.
#[tauri::command]
fn login(username: String, password: String) -> Result<String, String> {
if username == "admin" && password == "secret" {
Ok("logged_in".to_string())
} else {
Err("invalid credentials".to_string())
}
}
In React, use a try/catch block around the invoke call. The caught error contains the error message string.
async function handleLogin() {
try {
const token = await invoke<string>("login", {
username: "admin",
password: "wrong",
});
console.log(token);
} catch (error) {
console.error("Login failed:", error);
// error will be "invalid credentials"
}
}
Ignoring promise rejections:
If you call invoke without a catch (or try/catch) and the command returns an error, the promise rejection will be unhandled. This can cause silent failures or console warnings. Always handle errors from commands that can fail.
For more structured error handling, you can define a custom error enum in Rust that serializes a kind tag, making it easy to distinguish error types on the frontend. This is covered in the error handling deep‑dive later in this chapter.
Working with Asynchronous Commands
Rust commands can be async. From the React perspective they behave identically: invoke returns a Promise, and you await it. The Rust function might perform I/O, call other async functions, or just simulate latency, but the calling code does not change.
#[tauri::command]
async fn fetch_title(url: String) -> Result<String, String> {
let body = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
Ok(body)
}
const title = await invoke<string>("fetch_title", {
url: "https://example.com",
});
There is nothing special about invoke with async commands – the frontend simply awaits the resolution.
Blocking the main thread:
An async Rust command does not block the main Tauri process, so it is safe for long-running operations. However, the frontend will still wait for the Promise to resolve. Consider using an event‑based or channel‑based approach for streaming data or very long tasks.
Summary
The invoke function is the single entry point for calling Rust from React. It takes a command name and an optional arguments object, always returns a Promise, and respects the Rust return type and error handling automatically. The key points to remember are:
- Import
invokefrom@tauri-apps/api/core. - Use the exact command name string.
- Match JavaScript object keys to Rust parameter names, applying camelCase/snake_case conversion where applicable.
- Always handle promise rejections with
try/catchfor commands that can fail. - The pattern is the same whether the Rust command is synchronous or
async.