Calling Rust from React with commands
Step 4 of 13, and the step where rustnote stops being a template. Step 2 left you with the scaffold’s greet demo, and step 3 named the three layers its one call crossed. This step replaces that demo with commands of your own: a plain Rust function becomes a command, the command gets registered, React calls it with invoke(), and errors come back as promise rejections. The two details the template glosses over get their due as well — argument names change spelling at the boundary, and a struct needs Serialize before it can cross.
Start with a plain function
Section titled “Start with a plain function”fn add(first_number: i32, second_number: i32) -> i32 { first_number + second_number}
fn main() { println!("{}", add(19, 23));}42Nothing in that code mentions Tauri, because a command starts life as an ordinary function. If it compiles and returns a value, it can cross the boundary.
The attribute, then the registry
Section titled “The attribute, then the registry”Two additions make add reachable from the webview:
#[tauri::command]fn add(first_number: i32, second_number: i32) -> i32 { first_number + second_number}
fn main() { let _builder = tauri::Builder::default() .invoke_handler(tauri::generate_handler![add]);}#[tauri::command] leaves add callable from Rust exactly as before, and generates the plumbing the runtime needs: a hidden handler that deserialises JSON arguments into the parameters, calls the function, and serialises the return value back. generate_handler![add] takes that hidden handler and registers it on the router the Builder carries. Your function is the code; the two macros are the road in and out.
In rustnote both live in src-tauri/src/lib.rs, and the registry line replaces the template’s generate_handler![greet].
The registration you forget
Section titled “The registration you forget”The registry line is the one people forget, and forgetting it does not fail the build. The command compiles, the window opens, and the failure only shows up at call time. Suppose you write a second command, sub, and call it from React — but never add it to generate_handler!:
// sub is defined with #[tauri::command], but generate_handler! does not list itawait invoke("sub", { minuend: 10, subtrahend: 4 });Command sub not foundThe rejection says exactly that: the router has no command with that name. It looks like a network bug or a typo in the string, and it is neither — the command exists in Rust, it just was never registered.
There is a second way to get it wrong, and this one fails at compile time: listing a function that never got the attribute.
fn add(first_number: i32, second_number: i32) -> i32 { first_number + second_number}
fn main() { let _builder = tauri::Builder::default() .invoke_handler(tauri::generate_handler![add]);}Compiling that file on its own gives two errors, and neither mentions registration:
error: cannot find macro `__tauri_command_name_add` in this scope --> src/main.rs:7:25 |7 | .invoke_handler(tauri::generate_handler![add]); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: this error originates in the macro `tauri::generate_handler` (in Nightly builds, run with -Z macro-backtrace for more info)
error: cannot find macro `__cmd__add` in this scope --> src/main.rs:7:50 |7 | .invoke_handler(tauri::generate_handler![add]); | ^^^
error: could not compile `verify` (bin "verify") due to 2 previous errorsThe crate name in the last line is the scratch project’s; in rustnote it would say rustnote. generate_handler! does not look up functions — it looks up the hidden plumbing the attribute generates, two macros named after your command (__cmd__add and friends). No #[tauri::command], no macros, no registration. An error naming macros you never wrote is confusing until you know the attribute put them there.
Argument names change spelling at the boundary
Section titled “Argument names change spelling at the boundary”Calling the command from React looks like this, from the add button in App.tsx:
const value = await invoke<number>("add", { firstNumber: Number(a), secondNumber: Number(b),});setResult(String(value));The Rust parameters are first_number and second_number; the invoke keys are firstNumber and secondNumber. Tauri matches arguments by the camelCase spelling of the Rust parameter name, so that is what the front end sends.
Pass the snake_case spelling and the command never runs — the arguments are rejected first:
await invoke("add", { first_number: 1, second_number: 2 });invalid args `firstNumber` for command `add`: command add missing required key firstNumberRead the error twice: the runtime went looking for firstNumber, the camelCase form, and found nothing. A key you left out entirely fails the same way — drop secondNumber from the call above and the rejection reports secondNumber. The parameter list in Rust is the contract; the spelling on the JavaScript side is the camelCase form of it.
Errors cross as rejections
Section titled “Errors cross as rejections”add can always succeed. Most commands cannot, and the way to say so is the way Rust always says it — a Result:
#[tauri::command]fn divide(dividend: i32, divisor: i32) -> Result<i32, String> { if divisor == 0 { Err("cannot divide by zero".into()) } else { Ok(dividend / divisor) }}The return type splits the road. Ok resolves the promise, Err rejects it, and the rejection value is the error itself — a string here, unwrapped, no envelope around it:
await invoke("divide", { dividend: 7, divisor: 0 });cannot divide by zeroYour catch receives that string directly, so the UI can print it with ${e} — no .message, no unwrapping. On the happy path, dividing 7 by 2 resolves to 3: integer division truncates in Rust before anything crosses the boundary.
String is the simplest error type that works. Anything that serialises can sit in the Err position, and richer error types cross the same way — as long as they implement Serialize.
Structs need Serialize
Section titled “Structs need Serialize”Numbers and strings cross the boundary as themselves. Anything with shape needs serde:
use serde::Serialize;
#[derive(Serialize)]pub struct Stats { count: usize, total: i32, mean: f64,}
#[tauri::command]fn stats(numbers: Vec<i32>) -> Stats { let count = numbers.len(); let total: i32 = numbers.iter().sum(); let mean = if count > 0 { total as f64 / count as f64 } else { 0.0 }; Stats { count, total, mean }}The front end calls it and receives a plain object — the struct, serialised:
const s = await invoke<Stats>("stats", { numbers: [3, 1, 4, 1, 5, 9, 2, 6] });{"count":8,"total":31,"mean":3.875}On the TypeScript side that shape needs its own declaration — type Stats = { count: number; total: number; mean: number } — because the Rust type means nothing to the compiler that checks your front end. The two declarations drift independently; the JSON above is the only thing keeping them honest.
Drop the derive and the build breaks — the generated handler cannot send Stats across the boundary:
use serde::Serialize;
struct Stats { count: usize, total: i32, mean: f64,}
#[tauri::command]fn stats(numbers: Vec<i32>) -> Stats { let count = numbers.len(); let total: i32 = numbers.iter().sum(); let mean = if count > 0 { total as f64 / count as f64 } else { 0.0 }; Stats { count, total, mean }}
fn main() { let _builder = tauri::Builder::default() .invoke_handler(tauri::generate_handler![stats]);}warning: unused import: `serde::Serialize` --> src/main.rs:1:5 |1 | use serde::Serialize; | ^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
error[E0599]: the method `blocking_kind` exists for reference `&Stats`, but its trait bounds were not satisfied --> src/main.rs:9:1 | 3 | struct Stats { | ------------ doesn't satisfy `Stats: IpcResponse`... 9 | #[tauri::command] | ^^^^^^^^^^^^^^^^^ method cannot be called on `&Stats` due to unsatisfied trait bounds... 23 | .invoke_handler(tauri::generate_handler![stats]); | ------------------------------- in this macro invocation | = note: the following trait bounds were not satisfied: `Stats: IpcResponse` which is required by `&Stats: tauri::ipc::private::ResponseKind`note: the trait `IpcResponse` must be implemented --> /Users/rango/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.4/src/ipc/mod.rs:176:1 |176 | pub trait IpcResponse { | ^^^^^^^^^^^^^^^^^^^^^ = help: items from traits can only be used if the trait is implemented and in scope = note: the following traits define an item `blocking_kind`, perhaps you need to implement one of them: candidate #1: `tauri::ipc::private::ResponseKind` candidate #2: `tauri::ipc::private::ResultKind` = note: this error originates in the macro `__cmd__stats` which comes from the expansion of the macro `tauri::generate_handler` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0599`.The trait that is missing is IpcResponse, Tauri’s “this can go back over the wire” trait — and it has a blanket implementation for every type that implements Serialize. The derive is what satisfies it. Serialize comes from serde, which the scaffold already put in src-tauri/Cargo.toml next to serde_json — this step adds no dependencies. That detail matters soon: step 6 sends a Vec<Note> across this same boundary, and every Note will need the same derive.
The two files, in full
Section titled “The two files, in full”src-tauri/src/lib.rs loses the template’s greet and gains three commands:
use serde::Serialize;
#[derive(Serialize)]pub struct Stats { count: usize, total: i32, mean: f64,}
#[tauri::command]fn add(first_number: i32, second_number: i32) -> i32 { first_number + second_number}
#[tauri::command]fn divide(dividend: i32, divisor: i32) -> Result<i32, String> { if divisor == 0 { Err("cannot divide by zero".into()) } else { Ok(dividend / divisor) }}
#[tauri::command]fn stats(numbers: Vec<i32>) -> Stats { let count = numbers.len(); let total: i32 = numbers.iter().sum(); let mean = if count > 0 { total as f64 / count as f64 } else { 0.0 }; Stats { count, total, mean }}
#[cfg_attr(mobile, tauri::mobile_entry_point)]pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![add, divide, stats]) .run(tauri::generate_context!()) .expect("error while running tauri application");}src/App.tsx loses the logos and the greet form, and wires the three commands to buttons:
import { useState } from "react";import { invoke } from "@tauri-apps/api/core";import "./App.css";
type Stats = { count: number; total: number; mean: number };
const SAMPLE = [3, 1, 4, 1, 5, 9, 2, 6];
function App() { const [a, setA] = useState("42"); const [b, setB] = useState("7"); const [result, setResult] = useState("");
async function add() { try { const value = await invoke<number>("add", { firstNumber: Number(a), secondNumber: Number(b), }); setResult(String(value)); } catch (e) { setResult(`Error: ${e}`); } }
async function divide() { try { const value = await invoke<number>("divide", { dividend: Number(a), divisor: Number(b), }); setResult(String(value)); } catch (e) { setResult(`Error: ${e}`); } }
async function summarise() { const s = await invoke<Stats>("stats", { numbers: SAMPLE }); setResult(`${s.count} numbers, total ${s.total}, mean ${s.mean.toFixed(3)}`); }
return ( <main className="container"> <h1>rustnote</h1> <p> Three commands, registered in <code>generate_handler!</code> and called with <code>invoke()</code>. </p> <div className="row"> <input value={a} onChange={(e) => setA(e.currentTarget.value)} aria-label="first number" /> <input value={b} onChange={(e) => setB(e.currentTarget.value)} aria-label="second number" /> </div> <div className="row"> <button onClick={add}>Add</button> <button onClick={divide}>Divide</button> <button onClick={summarise}>Summarise the sample</button> </div> <p>{result}</p> </main> );}
export default App;main.rs, tauri.conf.json, and Cargo.toml are untouched — main.rs is still the three lines that call rustnote_lib::run(), and serde was already a dependency. Type in two numbers and Add sums them; Divide with 0 in the second field prints the error string; Summarise the sample prints the Stats line for the fixed list. The window is now a test bench for the boundary, and every step after this adds commands the same way: function, attribute, registry, call.
Where the code stands
Section titled “Where the code stands”The step is tagged tauri-ch03 in the examples repository:
git clone https://github.com/hackingwithrust/examplescd examples/tauri-desktop-appgit checkout tauri-ch03npm installnpm run tauri devIf you have been following along, your tree is current, and npm run tauri dev shows the three-button window.
Step 5 starts storing things. SQLite arrives, the commands grow a State parameter, and the pattern this page built — plain function in, value out — picks up its first piece of real persistence. The track overview keeps the full list current as articles land.