Connecting Backend to Frontend (React JS + Vite)
Understand how the Rust backend and React frontend communicate in Tauri v2 using commands, events and shared state
Every Tauri application runs two programs that must talk to each other: the Rust backend and the JavaScript frontend. This section covers the communication bridge that makes them work together — how to define functions in Rust, call them from React, pass data both ways, handle errors, and stream events from the backend to the UI.
The mechanisms described here are what turn a Tauri app from a static web page into a desktop application with full access to the system.
Understanding Frontend ↔ Rust Communication
A Tauri application consists of two processes. The frontend runs inside a webview — a stripped-down browser that renders the React application built with Vite. The backend runs native Rust code that has access to the file system, operating system APIs, and any other native capabilities. The Understanding Frontend ↔ Rust Communication page expands on this IPC model.
The webview cannot call Rust directly. It cannot open a file, read system memory, or launch a child process on its own. Rust code handles these privileged operations, and the frontend asks it to do them through a well-defined message-passing system.
Webview Sandbox:
The webview is sandboxed by default. Even though the app is local, the frontend cannot bypass Tauri to access the operating system. All system interaction must go through the Tauri API or custom Rust commands.
Tauri provides two primary channels for this communication: commands and events. Commands are request-response calls — the frontend calls a Rust function and waits for a result. Events are one-way broadcasts — Rust pushes data to the frontend without a prior request.
How Commands Work
A command is a Rust function annotated with #[tauri::command]. When the application starts, Tauri registers these functions and makes them callable from the frontend through a remote procedure call (RPC) mechanism.
From the React side, you call a command using the invoke function imported from @tauri-apps/api/core:
import { invoke } from "@tauri-apps/api/core";
The invoke function takes the command name as a string and an optional arguments object. It returns a promise that resolves with the data the Rust function returns. Tauri serializes arguments and return values using Serde under the hood, so any type that implements Serialize and Deserialize can cross the boundary.
When you call invoke("my_command", { key: "value" }), Tauri serializes the arguments to JSON, passes them to the Rust side, deserializes them into the function's parameters, executes the function, serializes the return value back to JSON, and resolves the JavaScript promise with the result.
Serialization is Implicit:
Every value that travels between Rust and JavaScript must be serializable. If you pass a Rust struct that doesn't derive Serialize and Deserialize, the build will fail with a compile-time error. This constraint is invisible until you break it — then the error messages can be difficult to decipher.
How Events Work
Commands require the frontend to initiate the interaction. Events flip that relationship. The Rust backend can emit an event at any time, and the frontend can listen for it. This is essential for long-running operations, background progress updates, or notifications triggered by system changes.
The Rust side emits events through the AppHandle or Window object. The frontend listens with the listen function from @tauri-apps/api/event. Events can carry an arbitrary payload, again serialized with Serde.
You will use commands for most CRUD operations and events for push-style notifications. The sections that follow cover both in detail.
Creating Your First Rust Command
A Rust command begins as an ordinary function with the #[tauri::command] attribute. The function can accept parameters and return a value. You then register it in lib.rs so the frontend can discover it. Follow the dedicated Creating Your First Rust Command walkthrough for the same example in isolation.
Open the src-tauri/src/lib.rs file. Replace the default Tauri setup with a builder that registers a simple greeting command:
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust.", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The greet function takes a string slice and returns a String. The attribute tells Tauri to treat it as a command. The generate_handler! macro collects all listed commands and wires them into the runtime.
Registration is Required:
A function decorated with #[tauri::command] is not automatically callable from the frontend. You must add it to the invoke_handler list. Forgetting this step causes invoke to reject with "command not found". This is the most common setup error.
Now the command exists, but the frontend needs to call it.
Calling Rust from React
Install the Tauri API package if you haven't already. The Calling Rust from React page covers this invoke pattern with more frontend detail.
npm install @tauri-apps/api
Open src/App.tsx (or App.jsx). Import invoke and call the greet command in response to a button click:
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
const [name, setName] = useState("");
const [greeting, setGreeting] = useState("");
async function handleGreet() {
try {
const response = await invoke<string>("greet", { name });
setGreeting(response);
} catch (error) {
console.error("Command failed:", error);
setGreeting("Could not reach the backend.");
}
}
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
<button onClick={handleGreet}>Greet</button>
<p>{greeting}</p>
</div>
);
}
export default App;
The invoke call is generic — you specify the expected return type in angle brackets to get proper TypeScript typing. The call itself is asynchronous, so handleGreet is async. The try...catch block is essential: if the command name is wrong, arguments don't match, or the Rust function panics, the promise rejects.
When you type a name and click the button, Tauri serializes { name: "Alice" }, the Rust function receives "Alice", and the frontend displays "Hello, Alice! You've been greeted from Rust.".
Command Names Must Match Exactly:
The first argument to invoke is the command name as it appears in the generate_handler! macro — not the Rust function name necessarily, though they usually match. Tauri derives the command name from the function identifier by converting it to snake_case. If you rename the Rust function, update the handler and the frontend string.
Passing Data Between React and Rust
The greet example passed a single string. You can pass numbers, booleans, arrays, and objects — any type that Serde can serialize and deserialize. See Passing Data Between React and Rust for the full set of examples.
Primitive Values and Multiple Parameters
Rust commands can accept multiple parameters of primitive types:
#[tauri::command]
fn calculate(a: i32, b: i32, operation: String) -> i32 {
match operation.as_str() {
"add" => a + b,
"subtract" => a - b,
"multiply" => a * b,
_ => 0,
}
}
Register it in generate_handler!. On the React side:
const result = await invoke<number>("calculate", {
a: 10,
b: 20,
operation: "add",
});
Tauri matches the JSON keys to the Rust parameter names. The order does not matter.
Passing Objects (Structs)
For structured data, define a Rust struct with Serialize and Deserialize derives and use it as a parameter:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct UserInput {
username: String,
age: u8,
email: Option<String>,
}
#[tauri::command]
fn register_user(input: UserInput) -> String {
format!(
"Registered {} (age {})",
input.username, input.age
)
}
From React, you pass a matching JavaScript object:
await invoke<string>("register_user", {
input: {
username: "alice",
age: 30,
email: "alice@example.com",
},
});
Notice that the Rust parameter is named input, so the JavaScript object has an input key whose value is the object. The nested fields match the struct fields. The email field is Option<String>; if you omit it from the JavaScript object, Serde will set it to None.
Serde Field Naming:
By default, Serde expects JSON keys to exactly match the struct field names. If your JavaScript convention uses camelCase and Rust uses snake_case, add #[serde(rename_all = "camelCase")] to the struct. This keeps the Rust code idiomatic while the frontend stays consistent.
Returning Data to React
Commands can return any serializable type. Primitive types work, but structured returns are more common. The Returning Data to React page collects the struct and collection examples.
Returning Structs
Define a response struct and return it:
#[derive(Serialize)]
struct UserResponse {
id: u32,
username: String,
created: bool,
}
#[tauri::command]
fn create_user(name: String) -> UserResponse {
UserResponse {
id: 42,
username: name,
created: true,
}
}
The frontend receives the data as a plain JavaScript object:
const user = await invoke<UserResponse>("create_user", { name: "bob" });
console.log(user.id, user.username); // 42, "bob"
Define a TypeScript interface to match the Rust struct:
interface UserResponse {
id: number;
username: string;
created: boolean;
}
Returning Collections
You can return vectors. They become JavaScript arrays:
#[tauri::command]
fn list_users() -> Vec<UserResponse> {
vec![
UserResponse { id: 1, username: "alice".into(), created: true },
UserResponse { id: 2, username: "bob".into(), created: false },
]
}
On the frontend, you get an array of objects. Iterate with map as you would with any array.
JSON Transparency:
Tauri uses JSON to bridge the two languages. Any Rust structure that serializes to valid JSON will appear as the corresponding JavaScript value. There is no hidden wrapper or transformation.
Error Handling
When a command can fail, return a Result<T, E> type. The E must implement Serialize so Tauri can send the error details to the frontend. See Error Handling for thiserror and frontend try/catch patterns.
Start with the simplest error type — a String:
#[tauri::command]
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Division by zero is not allowed.".to_string())
} else {
Ok(a / b)
}
}
In React, the catch block receives an error object with a message property containing the string:
try {
const result = await invoke<number>("divide", { a: 10, b: 0 });
console.log(result);
} catch (error) {
console.error(error); // "Division by zero is not allowed."
}
For more structured errors, define an enum that derives Serialize:
#[derive(Debug, Serialize)]
enum MathError {
DivisionByZero,
NegativeNumber,
}
#[tauri::command]
fn divide_advanced(a: f64, b: f64) -> Result<f64, MathError> {
if b == 0.0 { return Err(MathError::DivisionByZero); }
if a < 0.0 || b < 0.0 { return Err(MathError::NegativeNumber); }
Ok(a / b)
}
The frontend then receives an object like { DivisionByZero: null } or { NegativeNumber: null }, depending on the variant. You can inspect the keys to display user-friendly messages.
Error Payload Limitations:
The error type must be serializable, but the error message shown to the user is typically a stringified version. For complex error shapes, test how the serialized form looks in the frontend. Using a string error is the safest starting point.
Asynchronous Commands
Long-running tasks such as network requests, file I/O, or heavy computation should not block the main thread. Mark a command as async to run it on Tauri's async runtime. The Asynchronous Commands page covers this in more detail.
use std::time::Duration;
use tokio::time::sleep;
#[tauri::command]
async fn slow_process(seconds: u64) -> String {
sleep(Duration::from_secs(seconds)).await;
format!("Completed after {seconds} seconds")
}
The frontend call remains identical — invoke returns a promise, and await suspends the JavaScript until the Rust future completes. The webview stays responsive during the wait.
Internally, Tauri spawns async commands on a Tokio runtime. You can use any async Rust library that integrates with Tokio. Avoid running blocking code in an async command; if you must call a blocking function, use tokio::task::spawn_blocking.
Sync Commands Also Accept Async Calls:
Even synchronous commands are called with invoke which returns a promise, so the frontend always sees an asynchronous call. The Rust side just blocks a thread pool thread instead of yielding. Prefer async for anything that waits.
Use async commands when the operation takes more than a few milliseconds. Short synchronous commands are fine — they return quickly enough that the user won't notice.
Building a Simple CRUD Example
To demonstrate stateful interaction, you'll build an in-memory to-do list managed entirely in Rust. The backend holds a list of items behind a Mutex, and commands create, read, update, and delete entries. The full example is also on Building a Simple CRUD Example.
Setting Up Managed State
In lib.rs, define a struct to hold the application state and register it with the builder:
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Debug, Serialize, Deserialize, Clone)]
struct TodoItem {
id: u32,
title: String,
completed: bool,
}
struct AppState {
todos: Mutex<Vec<TodoItem>>,
}
#[tauri::command]
fn add_todo(state: State<'_, AppState>, title: String) -> TodoItem {
let mut todos = state.todos.lock().unwrap();
let id = todos.len() as u32 + 1;
let item = TodoItem { id, title, completed: false };
todos.push(item.clone());
item
}
#[tauri::command]
fn list_todos(state: State<'_, AppState>) -> Vec<TodoItem> {
let todos = state.todos.lock().unwrap();
todos.clone()
}
#[tauri::command]
fn toggle_todo(state: State<'_, AppState>, id: u32) -> Result<TodoItem, String> {
let mut todos = state.todos.lock().unwrap();
if let Some(item) = todos.iter_mut().find(|t| t.id == id) {
item.completed = !item.completed;
Ok(item.clone())
} else {
Err(format!("Todo with id {} not found", id))
}
}
#[tauri::command]
fn delete_todo(state: State<'_, AppState>, id: u32) -> Result<(), String> {
let mut todos = state.todos.lock().unwrap();
let initial_len = todos.len();
todos.retain(|t| t.id != id);
if todos.len() < initial_len {
Ok(())
} else {
Err(format!("Todo with id {} not found", id))
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState {
todos: Mutex::new(Vec::new()),
})
.invoke_handler(tauri::generate_handler![
add_todo,
list_todos,
toggle_todo,
delete_todo
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Mutex Poisoning:
If a command panics while holding the lock, the Mutex becomes poisoned. In production code, handle the unwrap() more gracefully by recovering or propagating the error. The simple examples use unwrap() for brevity, but a poisoned mutex would crash the entire app.
Frontend Integration
The React component calls these commands and maintains a local copy of the list. Each time the user adds, toggles, or deletes, the frontend re-fetches the full list to stay synchronized:
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface TodoItem {
id: number;
title: string;
completed: boolean;
}
function App() {
const [todos, setTodos] = useState<TodoItem[]>([]);
const [newTitle, setNewTitle] = useState("");
async function refreshTodos() {
try {
const items = await invoke<TodoItem[]>("list_todos");
setTodos(items);
} catch (error) {
console.error(error);
}
}
async function handleAdd() {
if (!newTitle.trim()) return;
try {
await invoke("add_todo", { title: newTitle });
setNewTitle("");
await refreshTodos();
} catch (error) {
console.error(error);
}
}
async function handleToggle(id: number) {
try {
await invoke("toggle_todo", { id });
await refreshTodos();
} catch (error) {
console.error(error);
}
}
async function handleDelete(id: number) {
try {
await invoke("delete_todo", { id });
await refreshTodos();
} catch (error) {
console.error(error);
}
}
useEffect(() => {
refreshTodos();
}, []);
return (
<div>
<h1>Todo List</h1>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="New todo"
/>
<button onClick={handleAdd}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{
textDecoration: todo.completed
? "line-through"
: "none",
}}
>
{todo.title}
</span>
<button onClick={() => handleToggle(todo.id)}>
{todo.completed ? "Undo" : "Done"}
</button>
<button onClick={() => handleDelete(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
export default App;
This pattern — call a command, then refresh the entire list — is the simplest way to keep the frontend in sync. For larger datasets, you might return only the affected item and update state locally. For now, the full refresh keeps the example clear.
Sending Events from Rust
Commands are pull-based: the frontend asks, the backend answers. Events are push-based: the backend sends data whenever it needs to, and the frontend reacts. See Sending Events from Rust.
Use events for:
- Progress updates during a long command (which cannot return intermediate results)
- Notifications from a background task
- System-wide messages that multiple windows should receive
Emitting an Event
Obtain an AppHandle in your command by accepting it as a parameter. Then call emit:
use tauri::AppHandle;
#[tauri::command]
async fn start_background_task(app: AppHandle, iterations: u32) -> String {
for i in 0..iterations {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
app.emit("task-progress", i + 1).unwrap();
}
"Task completed".to_string()
}
The first argument to emit is the event name — a string that the frontend uses to identify which listener to trigger. The second argument is the payload, which can be any serializable value.
You can also emit to a specific window using app.get_window("main").unwrap().emit(...). For most single-window apps, app.emit works fine.
Event Names are Global:
Event names are shared strings across your application. Choose specific, descriptive names to avoid collisions, especially when multiple plugins or components emit events.
Listening for Events in React
Import listen from @tauri-apps/api/event. The listen function registers a callback and returns a promise that resolves to an unlisten function — call that function to stop listening. The Listening for Events in React page focuses on the frontend side of this pair.
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
function ProgressTracker() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("");
useEffect(() => {
const unlistenPromise = listen<number>("task-progress", (event) => {
setProgress(event.payload);
});
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
async function startTask() {
try {
const result = await invoke<string>("start_background_task", {
iterations: 5,
});
setStatus(result);
} catch (error) {
console.error(error);
}
}
return (
<div>
<button onClick={startTask}>Start Task</button>
<p>Progress: {progress}</p>
<p>Status: {status}</p>
</div>
);
}
export default ProgressTracker;
The useEffect sets up the listener when the component mounts. The cleanup function calls unlisten to prevent memory leaks and stale callbacks. The event.payload contains whatever the Rust side emitted — in this case, a number.
Unlisten is an Async Operation:
listen returns a promise, so the cleanup must wait for it. The pattern above stores the promise and calls .then() in the cleanup. Forgetting to unlisten leads to multiple handlers stacking up if the component re-mounts.
When you click the button, the Rust command runs for five seconds, emitting a new number each second. The progress bar updates live in the React component.
Summary
The Rust backend and React frontend in a Tauri app communicate through two main pathways: commands for request-response interactions and events for push-based updates. Commands are the backbone — every time the frontend needs the backend to do something, it calls invoke. Arguments and return values serialize automatically through Serde, so you pass normal Rust types and receive plain JavaScript objects.
The most common pitfalls are forgetting to register a command in invoke_handler, mismatching argument names between the Rust function signature and the JavaScript object, and expecting invoke to be synchronous. Every invoke call is a promise — treat it as such.
Error handling with Result is straightforward: return Err in Rust, catch in JavaScript. Async commands let you run long operations without freezing the UI, and they require no special handling on the frontend.
Events add a reactive layer on top of the command pattern. When the backend needs to push information without being asked, emit an event and listen for it in React. The listener's cleanup is critical to avoid duplicate handlers.
With these primitives, you can build the entire logic layer of a desktop application in Rust while keeping the UI in React.
Understanding Frontend-Rust Communication
Learn how Tauri v2 bridges your React frontend and Rust backend, using commands, the invoke function, and IPC serialization to securely access native capabilities.
Creating Your First Rust Command
Learn how to create and register Tauri commands - the core mechanism that lets your React frontend call Rust functions with type safety
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.
Passing Data Between React and Rust
Learn how to send data from React to Rust functions in Tauri v2 using command arguments, structs, optional fields, and serde serialization.
Returning Data to React
Learn how to return primitive values, structs, vectors, and JSON from Tauri Rust commands to your React frontend, with serialization and error handling.
f. Error Handling
How to handle errors returned from Tauri Rust commands, propagate meaningful messages to the React frontend, and display user-friendly feedback.
Asynchronous Commands
Learn how to define and call async Tauri commands that keep your app responsive while Rust handles long-running tasks
Building a Simple CRUD Example
Build a complete Create, Read, Update, Delete application with Tauri v2 and React, using Rust for backend logic and in-memory storage
Sending Events from Rust
Learn how to emit events from Tauri's Rust backend to your React frontend using the event system, including global events, webview-specific events, and structured payloads
Listening for Events in React
How to listen for Tauri events emitted from Rust in a React frontend, handle payloads, and clean up listeners to avoid memory leaks.