Back to Blog

Tauri Frontend ↔ Backend Communication | Send Messages from React to Rust

Sandy LaneSandy Lane

Video: Tauri Frontend ↔ Backend Communication | Send Messages from React to Rust by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

invoke() for request-response. emit() for fire-and-forget. The two channels you use to wire React and Rust together.

In Tauri 2, the frontend and the Rust backend communicate through two distinct mechanisms. Use the right one for the right kind of message.

  • Commands (invoke()) — the frontend calls a Rust function and waits for a return value. Like an RPC.
  • Events (emit() / listen()) — either side fires off a message; whoever has subscribed receives it. No reply expected.

This lesson covers the patterns and when to choose each.

Commands: request-response

We saw the basic shape in the previous lesson. Here's a fuller example: a "Send Message" button that calls Rust and displays the response.

Rust side:

#[tauri::command]
fn process_message(text: String) -> String {
  format!("Backend received: '{}' ({} chars)", text, text.len())
}

// In Builder:
.invoke_handler(tauri::generate_handler![process_message])

Frontend side:

import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";

function App() {
  const [input, setInput] = useState("");
  const [response, setResponse] = useState("");

  async function send() {
    const result = await invoke<string>("process_message", { text: input });
    setResponse(result);
  }

  return (
    <main>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send}>Send</button>
      <p>{response}</p>
    </main>
  );
}

The frontend awaits the Rust function, the result becomes a React state update, the UI re-renders. Standard async / await.

This shape works whenever the frontend is the initiator — a user clicks a button, types in a field, navigates to a page. The frontend asks; the backend answers.

Events: fire-and-forget

For backend-initiated communication — progress updates, file watcher notifications, server-sent state — commands aren't enough. The frontend can't poll for "did anything happen?" thousands of times per second.

Events are the answer. The backend emits a named event with a payload; the frontend listens for that event name.

Backend:

use tauri::{AppHandle, Emitter};

#[tauri::command]
async fn long_task(app: AppHandle) -> Result<(), String> {
  for i in 0..=100 {
    app.emit("progress", i).map_err(|e| e.to_string())?;
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
  }
  Ok(())
}

Frontend:

import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";

function App() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const unlisten = listen<number>("progress", (event) => {
      setProgress(event.payload);
    });
    return () => { unlisten.then(fn => fn()); };
  }, []);

  return <progress value={progress} max={100}>{progress}%</progress>;
}

AppHandle::emit("name", payload) from Rust. listen("name", callback) from JS. The payload is serialised through the same JSON bridge as command arguments, so it can be a number, a string, or any Serializeable struct.

listen returns a promise that resolves to an unsubscribe function. Always call it on component unmount to avoid memory leaks.

Frontend → Backend events

Events flow both ways. The frontend can also emit:

import { emit } from "@tauri-apps/api/event";

await emit("user_action", { type: "click", target: "save" });

The backend can listen:

use tauri::{AppHandle, Listener};

app.listen("user_action", |event| {
  let payload = event.payload();    // raw JSON string
  // parse and react
});

This is useful for analytics, audit logging, or any "let the backend know something happened" pattern that doesn't need a response.

When to use which

Commands when: - The frontend needs a value back (file contents, query results). - The interaction is initiated by the user. - The operation is logically one call.

Events when: - The backend has new information at unpredictable times (progress, push notifications, streaming). - Multiple frontend components might want the same update (broadcast). - The communication is fire-and-forget (no reply expected).

A real app uses both. Save Document is a command (the frontend wants to know if the save succeeded). Document Auto-Saved is an event (the backend tells everyone listening, the frontend updates a "Last saved 2s ago" indicator).

Filtered events

emit_to(window_label, ...) sends an event only to a specific window:

app.emit_to("main", "ready", ()).ok();

In a multi-window app, this prevents accidentally broadcasting to all windows.

Channels: typed streams (Tauri 2)

Tauri 2 introduced typed channels for streaming data from a command:

use tauri::ipc::Channel;

#[tauri::command]
async fn stream_logs(on_event: Channel<String>) {
  for line in fetch_log_lines() {
    on_event.send(line).unwrap();
  }
}

Frontend:

import { Channel } from "@tauri-apps/api/core";

const channel = new Channel<string>();
channel.onmessage = (line) => console.log(line);
await invoke("stream_logs", { onEvent: channel });

A channel is like an event but scoped to a single invoke call — only the caller receives messages on it. Cleaner than emitting global events when the consumer is exactly one component.

Common mistakes

Forgetting to unsubscribe events. listen returns an unsubscribe function. Call it when the component unmounts, otherwise listeners accumulate every time the component remounts.

Emitting from sync commands. app.emit works in any context. tokio::time::sleep doesn't — use sync std::thread::sleep or make the command async.

Using events when a command would do. "Get user info" should be a command (you want the value back). Events for "the value changed; here's the new one" patterns.

Sending too much through one event. A 10MB payload over an event is slow. Stream with channels, or store the data and emit just an ID.

What's next

Next lesson: events deep dive — bidirectional patterns, channels, and the right shape for real-time apps. We'll build a clipboard manager and a file watcher.

Recap

Two channels: commands (invoke#[tauri::command]) for request-response, events (emit / listen) for fire-and-forget broadcast, channels for streaming results from a single command. Use commands when the frontend asks for something. Use events when the backend has news. Always unsubscribe listeners on unmount.

Next: events deep dive. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.