Back to Blog

Tauri Events: Real-Time Bidirectional Communication | Rust + React Tutorial (Lesson 34)

Sandy LaneSandy Lane

Video: Tauri Events: Real-Time Bidirectional Communication | Rust + React Tutorial (Lesson 34) by Taught by Celeste AI - AI Coding Coach

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

app.emit("name", payload) from Rust. listen("name", cb) from JavaScript. The pattern for progress bars, push notifications, and any event-driven UI.

The previous lesson covered commands and events at a high level. Today we focus on events specifically — the real-time communication pattern that makes Tauri apps feel responsive without polling.

What we are building

A small example: a Rust task that emits progress updates as it runs, and a React component that displays a live progress bar. Plus a frontend-to-backend event for "user requested cancellation."

Backend: emitting events

use tauri::{AppHandle, Emitter};

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

Two events: task-progress (number, 0..=100) and task-complete (a string message at the end).

app.emit(name, payload) requires AppHandle (an injectable parameter for any Tauri command). The Emitter trait provides the .emit method. Payloads are anything Serializeable — primitives, structs, vectors.

The event name is a string. There is no central registry of event names; both sides agree on a string and stay consistent. Conventionally, dash-separated lowercase: task-progress, file-changed, notification-clicked.

Frontend: listening for events

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

function ProgressDemo() {
  const [progress, setProgress] = useState(0);
  const [complete, setComplete] = useState<string | null>(null);

  useEffect(() => {
    let unlistenProgress: UnlistenFn;
    let unlistenComplete: UnlistenFn;

    (async () => {
      unlistenProgress = await listen<number>("task-progress", (event) => {
        setProgress(event.payload);
      });
      unlistenComplete = await listen<string>("task-complete", (event) => {
        setComplete(event.payload);
      });
    })();

    return () => {
      unlistenProgress?.();
      unlistenComplete?.();
    };
  }, []);

  return (
    <div>
      <progress value={progress} max={100}>{progress}%</progress>
      <button onClick={() => invoke("long_running_task")}>Start</button>
      {complete && <p>{complete}</p>}
    </div>
  );
}

listen<T>(name, callback) registers a handler. The callback receives an event object with .payload typed as T.

listen returns a promise that resolves to an UnlistenFn — a no-arg function you call to unsubscribe. The useEffect cleanup function calls it when the component unmounts.

The async-IIFE pattern ((async () => { ... })() inside useEffect) works because useEffect cannot directly accept an async function but can call one inside. The cleanup function returned synchronously can refer to the unsubscribe function via closure.

Event scoping

By default, emit broadcasts to all windows. For multi-window apps, scope the emit to a specific window:

app.emit_to("main", "task-progress", i).ok();

Or to several:

app.emit_filter("task-progress", i, |window| {
  window.label() == "main" || window.label() == "preview"
}).ok();

The frontend listen is window-scoped automatically — it only receives events for its own window. So if you emit_to("main", ...) and listen in the "preview" window, the preview window doesn't fire.

Frontend → backend events

The frontend can emit too:

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

await emit("user-cancel", null);

Backend listens:

use tauri::{AppHandle, Listener};

let app_handle = app.handle().clone();
app.listen("user-cancel", move |_event| {
  println!("User cancelled!");
  // signal a cancellation flag, etc.
});

This is the pattern for letting the user interrupt a long-running task. The Rust task checks an Arc<AtomicBool> between iterations; the frontend flips the bool by emitting user-cancel.

Payload types

Events carry one payload. It can be:

  • A primitive: 42, "hello", true.
  • A struct: #[derive(Serialize)] struct Notification { title: String, body: String }.
  • A vector: vec![1, 2, 3].
  • Unit: () if no payload is needed.

Whatever you emit must be Serialize on the Rust side and is decoded as JSON on the JS side.

#[derive(Serialize, Clone)]
struct FileChanged {
  path: String,
  kind: String,
}

app.emit("file-changed", FileChanged {
  path: "/some/path.txt".into(),
  kind: "modified".into(),
}).ok();
interface FileChanged { path: string; kind: string; }
listen<FileChanged>("file-changed", (event) => {
  console.log(event.payload.path, event.payload.kind);
});

For non-trivial payloads, define an interface on the JS side that mirrors the Rust struct.

Single-call channels (Tauri 2 specific)

Tauri 2 added Channel for streaming results from a single invoke call:

use tauri::ipc::Channel;

#[tauri::command]
async fn read_lines(file: String, on_line: Channel<String>) -> Result<(), String> {
  let lines = std::fs::read_to_string(&file).map_err(|e| e.to_string())?;
  for line in lines.lines() {
    on_line.send(line.to_string()).ok();
  }
  Ok(())
}
import { Channel } from "@tauri-apps/api/core";

const channel = new Channel<string>();
channel.onmessage = (line) => {
  console.log("line:", line);
};
await invoke("read_lines", { file: "/some/file.txt", onLine: channel });

Channels are scoped: only the caller of this invoke receives messages on this channel. Cleaner than global events when the consumer is exactly one component.

When to use events vs commands vs channels

Pattern Use when
Command (invoke / return value) Frontend asks, backend answers, one round trip
Event (emit / listen) Backend pushes to multiple subscribers; "broadcast"
Channel (Channel + invoke) Single command needs to stream multiple results

For a download with progress, use a channel: one user triggered the download, one component cares about the progress, one stream of values.

For a global "settings changed; everyone re-render" message, use an event: many components subscribe, the trigger is global.

Common mistakes

Forgetting to unlisten. The cleanup function in useEffect must call the unsubscribe. Otherwise listeners accumulate; component remounts double up.

Emitting at 1000 events/second. Each event is serialised, sent over IPC, deserialised. For very high frequency, batch into one event per ~50ms.

Inconsistent event names. Typo-prone. Define names as TypeScript constants and Rust constants if you're going to emit/listen from many places.

Listening with a stale closure. If the listener captures React state, it captures the initial value. Use refs (useRef) or include the state in the dependency list of useEffect to re-subscribe with the fresh state.

What's next

Next lesson: window customization. Frameless windows, custom titlebars, transparent backgrounds — the small visual details that make a Tauri app feel polished.

Recap

app.emit("name", payload) from Rust, listen("name", cb) from JS. Always unsubscribe on unmount. Use emit_to for window-scoped events. For streaming-from-one-command, use the Channel API. Pick events vs commands vs channels based on whether you need a return value, broadcast, or per-call streaming.

Next: window customization. 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.