Listening for Events in React
How to listen for Tauri events emitted from Rust in a React frontend, handle payloads, and clean up listeners to avoid memory leaks.
Tauri’s event system lets your Rust code push data to the frontend without the frontend asking for it. This is the right tool when you need server-style notifications, progress updates, or one-to-many communication from the backend. In React, you handle this with the @tauri-apps/api/event package and React's effect hooks.
The key difference from Tauri commands is that events are fire-and-forget from the backend's perspective. The frontend just listens. There’s no built‑in request‑response pairing, and event payloads are always JSON‑encoded under the hood.
How Tauri Events Reach the Browser
Before you can listen for an event, the Rust side must emit one. Your backend can fire global events (all webviews receive them) or target a specific webview. The snippet below emits a global tick event from a command, but events can be emitted from anywhere you have an AppHandle.
use tauri::{AppHandle, Emitter};
#[tauri::command]
fn start_clock(app: AppHandle) {
std::thread::spawn(move || {
let mut count = 0;
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
count += 1;
// Emit a global event with a payload
app.emit("tick", count).unwrap();
}
});
}
The emit method serialises the payload (here a number) to JSON and sends it to every webview that has a listener for "tick".
Setting Up a Basic Listener in React
The @tauri-apps/api/event module exports a listen function. You call it with an event name and a callback. The function returns a Promise that resolves to an unlisten handle — you’ll need that later for cleanup.
In a React component, the right place to register the listener is inside a useEffect so it runs after the component mounts.
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
function Clock() {
useEffect(() => {
// listen returns a Promise<UnlistenFn>
const unlistenPromise = listen<number>("tick", (event) => {
console.log("Tick payload:", event.payload);
});
// Cleanup: resolve the promise and call the unlisten function
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
return <div>Check the console for ticks.</div>;
}
export default Clock;
When this component mounts, the callback fires every time the Rust side emits "tick". The <number> generic tells TypeScript the payload will be a number. If the payload doesn’t match that shape at runtime, the callback still receives the raw value — TypeScript won’t guard you there.
Listener Active:
If you see tick numbers appearing in the browser console, the event channel is working correctly.
The unlisten Timing Trap
A common mistake is to treat the return value of listen as the unlisten function itself. It is not. listen returns a Promise that resolves to the function. If you call the promise object directly, nothing useful happens.
// ❌ Wrong: unlisten is a Promise, not a function
const unlisten = listen("tick", callback);
// The listener is never actually removed later.
// ✅ Correct: await the Promise to get the real function
const unlisten = await listen("tick", callback);
// Later: unlisten();
Inside useEffect you cannot use await directly because the effect callback must be synchronous. The pattern shown earlier — storing the promise and calling .then() inside the cleanup — is the idiomatic React solution.
Listening for Webview-Specific Events
If your backend emits events only to a particular webview (using emit_to), you need a different listener. Import getCurrentWebviewWindow from @tauri-apps/api/webviewWindow and call listen on the resulting object.
import { useEffect } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
function Dashboard() {
useEffect(() => {
const webview = getCurrentWebviewWindow();
const unlistenPromise = webview.listen<string>("user-logged-in", (event) => {
console.log("Logged in as:", event.payload);
});
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
return <div>Dashboard</div>;
}
Use this approach when the Rust code calls app.emit_to("webview-label", "event-name", payload). The label must match the webview’s identifier (usually "main" for the primary window). If you only ever have one window, you can stick with global events — the choice depends on whether you want to limit the event’s reach.
Receiving Typed Event Payloads
Real applications send more than a number. The Rust side often uses a struct with #[serde(rename_all = "camelCase")] so the JSON keys match JavaScript conventions. Define a matching TypeScript interface on the frontend.
use tauri::{AppHandle, Emitter};
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadProgress {
download_id: usize,
percentage: u8,
bytes_received: u64,
total_bytes: u64,
}
#[tauri::command]
fn download_file(app: AppHandle) {
for i in (0..=100).step_by(10) {
app.emit("download-progress", DownloadProgress {
download_id: 1,
percentage: i,
bytes_received: (i as u64 * 1024),
total_bytes: 10240,
}).unwrap();
}
}
Now the React listener uses the same structure.
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
interface DownloadProgress {
downloadId: number;
percentage: number;
bytesReceived: number;
totalBytes: number;
}
function DownloadBar() {
const [progress, setProgress] = useState<DownloadProgress | null>(null);
useEffect(() => {
const unlistenPromise = listen<DownloadProgress>(
"download-progress",
(event) => {
setProgress(event.payload);
}
);
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
if (!progress) return <p>Waiting for download...</p>;
return (
<div>
<progress value={progress.percentage} max={100} />
<p>
{progress.bytesReceived} / {progress.totalBytes} bytes
</p>
</div>
);
}
export default DownloadBar;
The event object inside the callback has more than payload. It also carries an id (unique per emission) and the event name. You rarely need those, but they are available if you want to deduplicate events.
Cleaning Up Listeners Properly
Every listener you register stays alive until you explicitly remove it. In a React component, that means you must call unlisten in the effect cleanup. The following pattern works reliably.
useEffect(() => {
let unlistenFn: (() => void) | undefined;
listen<string>("finish", (event) => {
// handle event
}).then((fn) => {
unlistenFn = fn;
});
return () => {
unlistenFn?.();
};
}, []);
Even better, if you need to unlisten after a specific event (say the download finishes and you no longer care about progress), call unlisten inside the callback. Watch out for the async nature: you must have the unlisten function ready.
useEffect(() => {
let unlistenFn: (() => void) | undefined;
listen<DownloadProgress>("download-progress", (event) => {
if (event.payload.percentage === 100 && unlistenFn) {
unlistenFn(); // Stop receiving further progress events
}
}).then((fn) => {
unlistenFn = fn;
});
return () => {
unlistenFn?.();
};
}, []);
Memory Leak Hazard:
Forgetting to clean up an event listener will cause it to persist even after the component unmounts. If the component remounts later, a new duplicate listener is added. Over time this leads to multiple callbacks firing for the same event, with each stale handler potentially referencing unmounted component state.
A Complete Download Tracker Example
Putting it all together: a Rust command emits progress events, and a React component displays a progress bar and stops listening when the download ends.
Rust Side
use tauri::{AppHandle, Emitter};
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadProgress {
percentage: u8,
}
#[tauri::command]
fn start_download(app: AppHandle) {
std::thread::spawn(move || {
for pct in [0, 20, 40, 60, 80, 100] {
std::thread::sleep(std::time::Duration::from_millis(500));
app.emit("download-progress", DownloadProgress { percentage: pct }).unwrap();
}
app.emit("download-finished", "all done").unwrap();
});
}
React Side
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
interface DownloadProgress {
percentage: number;
}
function DownloadTracker() {
const [progress, setProgress] = useState<number>(0);
const [finished, setFinished] = useState(false);
useEffect(() => {
let unlistenProgress: (() => void) | undefined;
let unlistenFinish: (() => void) | undefined;
listen<DownloadProgress>("download-progress", (event) => {
setProgress(event.payload.percentage);
if (event.payload.percentage === 100 && unlistenProgress) {
unlistenProgress();
}
}).then((fn) => {
unlistenProgress = fn;
});
listen<string>("download-finished", () => {
setFinished(true);
}).then((fn) => {
unlistenFinish = fn;
});
return () => {
unlistenProgress?.();
unlistenFinish?.();
};
}, []);
return (
<div>
<progress value={progress} max={100} />
{finished && <p>Download complete!</p>}
</div>
);
}
export default DownloadTracker;
Event System vs. Channels:
The event system is designed for small payloads and occasional pushes. If you need high‑throughput, ordered streaming (think a real‑time audio feed), use Tauri’s Channels instead. Events work well for progress updates, notifications, and status changes.
Common Pitfalls and How to Avoid Them
Unlisten called before the listener resolves
Calling unlisten synchronously on the promise returned by listen does nothing. The promise must resolve first. In an effect, always use the .then() pattern shown earlier.
Unlisten Timing:
If you need to unlisten immediately after registration, you must await the promise. However, inside useEffect you cannot use top‑level await — the cleanup function must resolve the promise and call the function. Storing the result in a ref or a variable and calling ?.() in cleanup is the safe approach.
Listening before the DOM is ready
React’s useEffect runs after the component mounts and the DOM is available. If you try to register a listener inside a useRef callback or during the initial render, DOM‑dependent side effects may fail because the element isn’t attached yet. Always put listener registration inside useEffect.
// ❌ Wrong: ref.current might be null
const ref = useRef<HTMLDivElement>(null);
listen("scroll-here", () => {
ref.current?.scrollIntoView(); // probably null
});
// ✅ Correct
useEffect(() => {
listen("scroll-here", () => {
ref.current?.scrollIntoView();
});
}, []);
Async listeners and event ordering
If your listener callback is async and the backend emits several events in quick succession, the handlers may execute out of order because each async function yields control. The event system does not guarantee sequential processing. For ordered, high‑frequency data, use Channels.
Ordering Not Guaranteed:
Do not rely on event ordering for critical logic. If you need strict ordering, switch to Tauri Channels or include a sequence number in each payload and sort them on the frontend.
Expecting large binary payloads
Event payloads are serialised as JSON strings. Sending a multi‑megabyte file through an event will be slow and may even fail silently. For large binary data, use Tauri’s tauri::ipc::Response with array buffers or a dedicated file‑reading command.
Summary
You now know how to receive data pushed from Rust in a React frontend without polling. The listen API from @tauri-apps/api/event gives you a clean way to subscribe to global and webview‑specific events, handle typed payloads, and tear down listeners safely.
The most important practical habits to take away are:
- Always clean up listeners in the
useEffectreturn callback. - Treat
listenas a promise — never callunlistenbefore the promise resolves. - Match your TypeScript interfaces to Rust
#[serde(rename_all = "camelCase")]structs.