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.

Tauri commands are the primary mechanism for the frontend to send data to Rust. When you call a command, any arguments you pass travel from the browser's JavaScript engine to the Rust backend as JSON. This document explains exactly how to structure that data, what types you can use, how to map field names between React and Rust, and how to avoid the most common pitfalls that cause deserialization failures.

Passing Primitive Values

The simplest form of data exchange is sending a single primitive value: a string, number, or boolean. In Rust you define a command with a typed parameter; in React you pass the value as a named property in the arguments object.

Rust — src-tauri/src/lib.rs

#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

Don't forget to register the command in the builder:

pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

React — src/App.tsx

import { invoke } from '@tauri-apps/api/core';
function App() {
  async function handleGreet() {
    const greeting = await invoke<string>('greet', { name: 'Alice' });
    console.log(greeting); // "Hello, Alice!"
  }
  return <button onClick={handleGreet}>Greet</button>;
}

Tauri automatically maps the argument key name to the Rust parameter name. The invoke generic <string> tells TypeScript what the resolved value should look like — it's not required but keeps your frontend types accurate.

Primitive arguments are serialized directly:

Strings, integers, floats, and booleans map straight to their Rust equivalents.
If the command runs and you see the expected output in the console, the data transfer is working correctly.

Passing Multiple Parameters

When a command needs several pieces of data, you list them as separate function parameters. On the React side, each parameter becomes a key in the arguments object.

Rust — src-tauri/src/lib.rs

#[tauri::command]
fn add(a: i32, b: i32) -> i32 {
    a + b
}

React — src/App.tsx

import { invoke } from '@tauri-apps/api/core';
async function calculateSum() {
  const result = await invoke<number>('add', { a: 5, b: 7 });
  console.log(result); // 12
}

Tauri’s command system uses the parameter name as the JSON key, and it converts snake_case to camelCase automatically. That means you always use camelCase keys from JavaScript, even if you later define a Rust parameter in snake_case. For example, a Rust parameter named first_number would be called as { firstNumber: 10 } from React.

Automatic casing conversion:

Tauri v2 translates command parameter names from snake_case to camelCase so the frontend never has to send snake_case keys. This only applies to direct command parameters — struct fields are handled by serde and need their own mapping.

Passing Structs

For anything beyond a handful of related fields, a struct is the right tool. The React side sends a plain JavaScript object, and serde deserializes it into the Rust type.

Defining and Passing a Struct

Rust — src-tauri/src/lib.rs

use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct User {
    first_name: String,
    age: u8,
    is_admin: bool,
}
#[tauri::command]
fn create_user(user: User) -> String {
    format!(
        "Created user {} (age {}, admin: {})",
        user.first_name, user.age, user.is_admin
    )
}

React — src/App.tsx

import { invoke } from '@tauri-apps/api/core';
async function handleCreateUser() {
  const message = await invoke<string>('create_user', {
    user: {
      firstName: 'Alice',
      age: 30,
      isAdmin: false,
    },
  });
  console.log(message);
}

Here the React object uses firstName (camelCase) but the Rust struct field is first_name. The #[serde(rename_all = "camelCase")] attribute tells serde to map camelCase JSON keys to snake_case fields during deserialization.

Struct fields need explicit serde renaming:

The automatic camelCase conversion Tauri does for command parameters does not extend to the fields inside a struct argument. If you skip #[serde(rename_all = "camelCase")] on the struct, React must send snake_case keys (first_name) instead of the usual camelCase convention. Leaving this out is the single most common reason for a deserialization error when passing objects.

Deserializing the Entire Arguments Object as a Struct

If a command expects a single struct argument, Tauri will deserialize the whole object passed to invoke into that struct. So you could flatten the call like this:

await invoke<string>('create_user', {
  firstName: 'Alice',
  age: 30,
  isAdmin: false,
});

In this flattened form, the Rust struct fields become direct keys in the arguments object. The #[serde(rename_all = "camelCase")] still applies and correctly maps firstName to first_name. This works because Tauri, when the only argument is a struct, treats the entire JSON payload as that struct.

Optional Values and Defaults

Not every field is always present. Rust’s Option<T> type signals that a value might be missing, and serde handles it gracefully.

Rust — src-tauri/src/lib.rs

use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Profile {
    display_name: String,
    bio: Option<String>,
    age: Option<u8>,
}
#[tauri::command]
fn update_profile(profile: Profile) -> String {
    let bio = profile.bio.unwrap_or_else(|| "No bio".into());
    let age = profile.age.map_or("unknown".into(), |a| a.to_string());
    format!("Profile: {} — {} (age {})", profile.display_name, bio, age)
}

React — src/App.tsx

await invoke<string>('update_profile', {
  displayName: 'Bob',
  // bio and age are omitted — they become None in Rust
});

Missing keys in the JSON become None. If the key is present but set to null, serde also treats it as None. This allows you to skip optional fields entirely without causing an error.

Providing Default Values with serde

Sometimes you want a field to receive a default value when the key is missing, without requiring the frontend to send it. Use #[serde(default)] on a field, or #[serde(default = "function")] to compute a custom default.

Rust — src-tauri/src/lib.rs

use serde::Deserialize;
fn default_theme() -> String {
    "light".into()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Settings {
    notifications_enabled: bool,
    #[serde(default = "default_theme")]
    theme: String,
    #[serde(default)]
    volume: u8, // defaults to 0
}

If React sends { notificationsEnabled: true }, Rust receives theme = "light" and volume = 0.

Option and default both work without client-side gymnastics:

Fields typed as Option<T> accept missing keys transparently. For cases where you want a non-optional Rust type but still want to accept missing keys, #[serde(default)] eliminates the need for the frontend to send every field.

Complex and Nested Data

Real applications often send arrays, dictionaries, or deeply nested structures. Serde handles all of these as long as the types implement Deserialize.

Rust — src-tauri/src/lib.rs

use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Tag {
    key: String,
    value: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Article {
    title: String,
    tags: Vec<Tag>,
    metadata: HashMap<String, String>,
}
#[tauri::command]
fn publish_article(article: Article) -> String {
    format!(
        "Published '{}' with {} tags and {} metadata entries",
        article.title,
        article.tags.len(),
        article.metadata.len()
    )
}

React — src/App.tsx

await invoke<string>('publish_article', {
  title: 'Tauri Guide',
  tags: [
    { key: 'topic', value: 'rust' },
    { key: 'level', value: 'beginner' },
  ],
  metadata: {
    author: 'Alice',
    published: '2026-07-08',
  },
});

Every nested type must implement Deserialize:

If a struct field contains a Vec, HashMap, or another custom type, that inner type must also derive Deserialize. Forgetting this on a nested struct produces a Rust compile error that points at the missing implementation.

Using serde_json::Value for Dynamic Data

When the shape of the data isn’t known at compile time, you can accept a serde_json::Value. This lets you receive any valid JSON and inspect it manually in Rust.

Rust — src-tauri/src/lib.rs

use serde_json::Value;
#[tauri::command]
fn handle_dynamic_payload(payload: Value) -> String {
    if let Some(name) = payload.get("name").and_then(|v| v.as_str()) {
        format!("Received payload for {}", name)
    } else {
        "Payload missing 'name' field".into()
    }
}

While flexible, this approach bypasses compile-time type checking and requires manual error handling inside the command. Use it sparingly — when you genuinely cannot define a static schema.

The Role of Serde in Serialization

Every piece of data that crosses the IPC boundary is serialized to JSON by the frontend (or by Tauri’s invoke internals) and deserialized by serde on the Rust side. Understanding a few key serde attributes will save you from puzzling runtime errors.

Field Renaming

The rename_all attribute on a struct applies a casing convention to all fields. Common values are camelCase, snake_case, PascalCase, and kebab-case. For a single field you can use #[serde(rename = "jsName")].

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Config {
    max_connections: u16,
    #[serde(rename = "serverURL")]
    server_url: String,
}

The frontend sends { maxConnections: 100, serverURL: "https://example.com" }, and Rust receives max_connections = 100, server_url = "https://example.com".

Ignoring Unknown Fields

By default, serde will return an error if the JSON contains a key that does not match any struct field. To silently ignore extra keys, add #[serde(deny_unknown_fields)]? Actually, to ignore unknown fields you need to not use deny_unknown_fields; by default unknown fields are ignored. If you want to reject them, you add #[serde(deny_unknown_fields)] to the struct. A common security practice is to reject unexpected keys so that the frontend can’t accidentally (or maliciously) inject data into a field that doesn’t exist.

#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct LoginRequest {
    username: String,
    password: String,
}

Now sending { username: "alice", password: "secret", isAdmin: true } will cause a deserialization error — protecting you from unintentional field injection.

Security through serde:

Denying unknown fields is a defensive measure that aligns with Tauri’s capability model. It ensures the frontend cannot influence the backend with keys you didn’t explicitly allow.

Flattening Nested Structures

Sometimes you want to embed fields from another struct without nesting the JSON. #[serde(flatten)] pulls all fields of an inner struct into the parent level.

#[derive(Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}
#[derive(Deserialize)]
struct SearchQuery {
    term: String,
    #[serde(flatten)]
    pagination: Pagination,
}

React sends { term: "rust", page: 1, perPage: 20 }, and it maps correctly because the Pagination struct’s fields (with its own rename_all if needed) are treated as direct keys.

Serializing Enums

Enums with data can be serialized using serde’s tagging mechanisms. The most common pattern for command arguments is the internally tagged enum, where a "type" field discriminates the variant.

#[derive(Deserialize)]
#[serde(tag = "action", rename_all = "camelCase")]
enum Command {
    Start { delay_ms: u64 },
    Stop { force: bool },
}

React sends { action: "start", delayMs: 500 } or { action: "stop", force: true }. Serde automatically selects the correct variant based on the action field.

Summary

Passing data from React to Rust in Tauri v2 is essentially a JSON serialization contract. Command arguments are always sent as a single JSON object; Tauri maps the top-level keys to Rust parameters (with automatic camelCase conversion), while struct fields require explicit serde annotations to align JavaScript camelCase conventions with Rust’s snake_case. Optional fields are handled cleanly with Option<T> or #[serde(default)], and the full power of serde — renaming, flattening, enum tagging, and unknown field rejection — gives you precise control over the shape of the data your backend accepts.