zudo-tauri-wisdom

Type to search...

to open search from anywhere

Tauri IPC Command Patterns

CreatedMar 29, 2026UpdatedApr 22, 2026Takeshi Takatsudo

Command registration, function signatures, state access, error handling, and async patterns for Tauri v2 IPC.

Command registration

All IPC commands must be registered in the invoke_handler macro in main.rs. This is the single source of truth for what the frontend can call:

.invoke_handler(tauri::generate_handler![
    // File operations
    commands::files::messages_list,
    commands::files::messages_read,
    commands::files::messages_write,
    commands::files::messages_delete,
    commands::files::messages_create,
    commands::files::draft_read,
    commands::files::draft_write,
    commands::files::draft_clear,
    // Settings
    commands::settings::settings_get,
    commands::settings::settings_save,
    // Workspace
    commands::workspace::workspace_switch,
    // Watchers
    commands::watchers::pins_watch_file,
    commands::watchers::pins_unwatch_file,
    // Native dialog
    native::dialog::open_directory,
])

⚠️ Warning

If a command is not listed in generate_handler!, calling it from the frontend will silently fail. There is no compile-time check that all #[tauri::command] functions are registered.

Command function signatures

Basic command

The #[tauri::command] attribute marks a function as callable from the frontend:

#[tauri::command]
pub fn get_home_dir() -> String {
    dirs::home_dir()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default()
}

Frontend call:

const homeDir = await invoke<string>("get_home_dir");

Command with parameters

Parameters are deserialized from the frontend’s argument object:

#[tauri::command]
pub fn messages_read(
    state: State<'_, Arc<AppState>>,
    filename: String,
) -> Option<String> {
    let root = get_project_root_string(&state).ok()?;
    let archives_dir = get_archives_dir(&root);
    match safe_path(&archives_dir, &filename) {
        Ok(path) => read_file_or_none(path.to_str()?),
        Err(_) => None,
    }
}

Frontend call:

const content = await invoke<string | null>("messages_read", {
  filename: "2024-01-15-meeting-notes.md",
});

πŸ“ Note

Parameter names in the Rust function must match the property names in the frontend’s argument object. Rust uses snake_case; the frontend must also use snake_case (not camelCase) for the keys.

Command with State<AppState>

Access shared application state through the State extractor. Note the Arc wrapping:

#[tauri::command]
pub fn settings_get(
    state: State<'_, Arc<AppState>>,
) -> Option<serde_json::Value> {
    let root = state
        .project_root
        .lock()
        .map_err(|e| format!("Failed to lock project root: {}", e))
        .ok()?
        .clone();
    if root.is_empty() {
        return None;
    }
    read_settings(&root, &**state)
}

State is injected automatically by Tauri β€” the frontend does not pass it:

// State is NOT passed from frontend -- it's injected by Tauri
const settings = await invoke("settings_get");

The &**state pattern dereferences through State and Arc to get a &AppState reference:

state: State<'_, Arc<AppState>>
  *state  ->  Arc<AppState>     (deref State)
  **state ->  AppState          (deref Arc)
  &**state -> &AppState         (borrow)

Return types and error handling

Returning Option<T>

Use Option when the absence of a value is not an error:

#[tauri::command]
pub fn draft_read(
    state: State<'_, Arc<AppState>>,
) -> Option<String> {
    let root = get_project_root_string(&state).ok()?;
    let path = get_draft_path(&root);
    read_file_or_none(&path) // Returns None if file doesn't exist
}

On the frontend, Option::None becomes null:

const content = await invoke<string | null>("draft_read");
if (content === null) {
  // No draft exists
}

Returning Result<T, String>

Use Result when you need to communicate error details:

#[tauri::command]
pub fn messages_create(
    state: State<'_, Arc<AppState>>,
    name: String,
    content: String,
) -> Result<String, String> {
    let root = get_project_root_string(&state)?; // ? propagates String error
    let archives_dir = get_archives_dir(&root);

    fs::create_dir_all(&archives_dir)
        .map_err(|e| format!("Failed to create archives dir: {}", e))?;

    let filename = generate_filename(&name);
    let path = safe_path(&archives_dir, &filename)?;
    fs::write(&path, &content)
        .map_err(|e| format!("Failed to write file: {}", e))?;
    Ok(filename)
}

On the frontend, Err rejects the promise:

try {
  const filename = await invoke<string>("messages_create", {
    name: "Meeting Notes",
    content: "# Meeting Notes\n\n...",
  });
  console.log("Created:", filename);
} catch (error) {
  // error is the String from Err(...)
  console.error("Failed:", error);
}

⚠️ Warning

Tauri v2 requires error types to be String (or implement Into<InvokeError>). You cannot return custom error structs directly. Use .map_err(|e| format!("...: {}", e)) to convert errors.

Returning bool

For simple success/failure without error details:

#[tauri::command]
pub fn settings_save(
    state: State<'_, Arc<AppState>>,
    settings: serde_json::Value,
) -> bool {
    let root = match state.project_root.lock() {
        Ok(r) => r.clone(),
        Err(_) => return false,
    };
    if root.is_empty() {
        return false;
    }
    if !settings.is_object() {
        return false;
    }
    save_settings(&root, &settings, &**state)
}

Returning serializable structs

Return complex data by deriving Serialize:

#[derive(Serialize, Clone, Debug)]
pub struct DraftDeleteResult {
    #[serde(rename = "newCount")]
    pub new_count: u32,
    #[serde(rename = "newActive")]
    pub new_active: u32,
}

#[tauri::command]
pub fn drafts_delete(
    state: State<'_, Arc<AppState>>,
    draft_number: u32,
) -> Option<DraftDeleteResult> {
    // ...
    Some(DraftDeleteResult {
        new_count,
        new_active,
    })
}

πŸ’‘ Tip

Use #[serde(rename = "camelCase")] to convert Rust’s snake_case field names to the camelCase convention expected by JavaScript/TypeScript frontends.

Async commands

For operations that need the Tauri async runtime (like native dialogs), make the command async:

#[tauri::command]
pub async fn open_directory(app: AppHandle) -> Option<String> {
    tauri::async_runtime::spawn_blocking(move || {
        app.dialog()
            .file()
            .blocking_pick_folder()
            .and_then(|fp| {
                fp.as_path()
                    .map(|p| p.to_string_lossy().to_string())
            })
    })
    .await
    .ok()
    .flatten()
}

πŸ“ Note

Async commands receive AppHandle instead of State when they need to own the handle across .await points. You can also receive both State and AppHandle β€” extract the data you need from State before the first .await.

Command module organization

Organize commands by domain in a commands/ directory:

src/commands/
  mod.rs          # pub mod declarations
  files.rs        # CRUD operations for files
  settings.rs     # Settings read/write
  watchers.rs     # File watcher management
  terminal.rs     # PTY/terminal commands
  workspace.rs    # Workspace switching
  window.rs       # Window management commands
  fonts.rs        # Font listing
// commands/mod.rs
pub mod files;
pub mod fonts;
pub mod settings;
pub mod terminal;
pub mod watchers;
pub mod window;
pub mod workspace;

Internal helper pattern

Extract shared logic into non-command helper functions:

// Not a command -- internal helper
fn get_project_root_string(
    state: &AppState,
) -> Result<String, String> {
    state
        .project_root
        .lock()
        .map(|r| r.clone())
        .map_err(|e| format!("Failed to lock project root: {}", e))
}

// Command that uses the helper
#[tauri::command]
pub fn draft_read(
    state: State<'_, Arc<AppState>>,
) -> Option<String> {
    let root = get_project_root_string(&state).ok()?;
    let path = get_draft_path(&root);
    read_file_or_none(&path)
}

This keeps commands thin and focused on parameter handling, while the real logic lives in reusable functions that can be called from multiple commands.

Calling IPC from a Bundled Loading Page

A wrapper app’s bundled frontend/index.html (the loading page) often has no bundler β€” it is plain HTML served from frontendDist for exactly long enough to show a spinner before navigating to the real dev server. When that page needs to listen for backend events or invoke custom commands (e.g., retry_launch from an error-state loading page), it cannot import from the Tauri JS SDK. The only option is window.__TAURI__.

Two config details have to line up, both easy to miss:

1. Enable withGlobalTauri

In Tauri v2, app.withGlobalTauri defaults to false. That means window.__TAURI__ does not exist in the webview at all, so window.__TAURI__.event.listen is TypeError: Cannot read properties of undefined. The page fails silently β€” no frontend error surfaces unless you explicitly log the exception.

// tauri.conf.json
{
  "app": {
    "withGlobalTauri": true
  }
}

⚠️ Warning

This is a common β€œwhy are my event listeners not firing?” trap. If your bundled loading page uses window.__TAURI__ at all and you did not explicitly set withGlobalTauri: true, nothing works and there is no frontend error. Add a defensive log at the top of the page so the failure is visible:

if (!window.__TAURI__) {
  console.error("window.__TAURI__ missing β€” set withGlobalTauri: true");
}

2. core:default already covers it

For a typical loading page that listens for an event and invokes one or two custom commands, core:default in capabilities/default.json is sufficient. It grants event:listen, event:unlisten, and the invoke plumbing that custom #[tauri::command] functions use. Do not speculatively broaden the capability to things like core:allow-emit-to-any β€” they are not needed and only widen the surface the webview can reach.

// capabilities/default.json β€” unchanged from the default
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["core:default"]
}

Using the globals

With those two in place, the bundled page can listen and invoke directly:

<script>
  const { event, core } = window.__TAURI__;

  // Listen for a backend event
  const unlisten = await event.listen("launch-error", ({ payload }) => {
    // payload is whatever Rust sent via Emitter::emit
  });

  // Invoke a custom command
  await core.invoke("retry_launch");
</script>

πŸ“ Note

window.__TAURI__.core.invoke is the same function as the ESM invoke re-exported by @tauri-apps/api/core. The payload serialization and return-value deserialization are identical β€” a bundled page does not get a degraded API, just a different import style.

Key takeaways

  1. Register every command in generate_handler! β€” there is no compile-time check
  2. Use State<'_, Arc<AppState>> β€” the Arc is needed when sharing state with background threads
  3. Return Result<T, String> for commands that can fail with details
  4. Return Option<T> when absence is normal (file not found, empty state)
  5. **Use .map_err(|e| format!(...)) ** to convert all error types to String
  6. Derive Serialize with serde(rename) for frontend-friendly field names
  7. Keep commands thin β€” delegate to internal helper functions
  8. Bundled loading pages need withGlobalTauri: true β€” or window.__TAURI__ silently does not exist