Tauri Events: Real-Time Bidirectional Communication | Rust + React Tutorial (Lesson 34)
Video: Tauri Events: Real-Time Bidirectional Communication | Rust + React Tutorial (Lesson 34) by Taught by Celeste AI - AI Coding Coach
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.