Event-Driven Communication Between Rust and React in a Tauri App

Event-Driven Communication Between Rust and React in a Tauri App

Published
Updated
...Part of Path of Exile 2 Overlord

In Part 1 I called the architecture a "nervous system" – the log file is the sensory input, domain services are the brain, and the event bus is the wiring that carries signals to the rest of the body. Parts 2 and 3 went deep on the brain and the sensory input. Now we're finally going to trace the wiring.

This is the part where I had to sit down and really think about how two completely different runtimes – a Rust backend and a React frontend – should talk to each other in real time. When a zone change gets parsed from a log file, how does that information make it all the way to a React component that re-renders with the new zone name? And when the user clicks "Create Character" in the UI, how does that request reach the Rust service?

The answer is two separate communication channels: events flow from backend to frontend through a pub/sub event bus and a Tauri bridge, and commands flow from frontend to backend through Tauri's invoke IPC system.

The event bus: pub/sub in Rust

The event bus is an in-memory publish-subscribe system. Any domain service can publish an event, and any subscriber receives it. In practice, there's one subscriber that matters – the Tauri event bridge, which forwards everything to the frontend. But the bus supports multiple subscribers, which makes it easy to add things like event logging or metrics down the road.

Publishing is straightforward. You hand the bus an AppEvent, it figures out which channel to send it to based on the event type, and broadcasts it:

pub async fn publish(&self, event: AppEvent) -> AppResult<()> {
    let event_type = event.event_type();
    let channel = self.channel_manager.get_or_create_channel(event_type).await?;
    channel.sender().send(event.clone())?;
    Ok(())
}

Channels are created lazily on first use. Different event types get different buffer sizes depending on how chatty they are – character tracking events get a larger buffer because zone changes can come in bursts.

The AppEvent enum defines every event the system can produce – CharacterUpdated, CharacterDeleted, WalkthroughStepCompleted, GameProcessStatusChanged, and so on. Each variant carries a timestamp plus whatever payload is relevant.

The Tauri event bridge

The event bus is great for Rust-to-Rust communication, but the frontend runs in a webview. It can't subscribe to a tokio broadcast channel. We need something to sit in between and translate domain events into something the webview understands.

That's the TauriEventBridge. It subscribes to every event type on the bus, and when an event comes in, it serializes it and calls window.emit() to push it into the frontend:

pub async fn start_forwarding(&self) -> AppResult<()> {
    for event_type in EventType::all() {
        let event_bus = Arc::clone(&self.event_bus);
        let window = self.window.clone();
 
        tokio::spawn(async move {
            if let Ok(mut receiver) = event_bus.get_receiver(event_type).await {
                while let Ok(event) = receiver.recv().await {
                    Self::forward_event_to_frontend(&window, &event).await;
                }
            }
        });
    }
    Ok(())
}

For each event type, it spawns a separate tokio task that loops forever, receiving events and forwarding them. Serde handles the serialization automatically – the AppEvent enum derives Serialize, so each variant becomes a tagged JSON object like { CharacterUpdated: { character_id: "...", data: {...} } }.

Listening on the React side

The frontend catches these events through a useAppEventListener hook that wraps Tauri's listen() function:

export function useAppEventListener(
  listeners: Array<{ eventType: keyof AppEventRegistry; handler: (payload: unknown) => void }>,
  deps: React.DependencyList = []
) {
  useEffect(() => {
    const unlistenFns: UnlistenFn[] = [];
    const setup = async () => {
      const promises = listeners.map(({ eventType, handler }) =>
        listenToAppEvent(eventType, handler)
      );
      unlistenFns.push(...(await Promise.all(promises)));
    };
    setup();
    return () => unlistenFns.forEach(unlisten => unlisten());
  }, [...deps]);
}

You pass in an array of event types and their handlers, and the hook sets up all the Tauri listeners on mount and tears them down on unmount.

Event names are centralized in a registry so Rust and TypeScript stay in sync. If you try to listen for "character-updatedd" (with a typo), the type checker catches it.

How contexts consume events

The CharacterContext is a good example of putting this together. It handles both event-driven updates and initial data loading through React Query:

const { isListening } = useAppEventListener([
  {
    eventType: EVENT_KEYS.CharacterUpdated,
    handler: (payload) => {
      const { character_id, data } = payload;
      setCharactersWithUpdates(prev =>
        prev.map(char => char.id === character_id ? data : char)
      );
    },
  },
  {
    eventType: EVENT_KEYS.CharacterDeleted,
    handler: (payload) => {
      const { character_id } = payload;
      setCharactersWithUpdates(prev =>
        prev.filter(char => char.id !== character_id)
      );
    },
  },
], []);

Notice the functional state updatessetCharactersWithUpdates(prev => ...) instead of setCharactersWithUpdates(newValue). This matters because the event handler is created once when the effect runs, and it captures whatever variables are in scope at that moment. If we referenced charactersWithUpdates directly inside the handler, we'd be reading the value from when the listener was set up, not the current value. That's a stale closure.

I didn't catch this the first time around. The symptoms were bizarre – character updates would work perfectly for the first one, then subsequent events would appear to "undo" previous changes. Functional updates fix this because React always gives you the latest state value as the prev argument.

The other direction: commands

Events handle backend-to-frontend communication. For frontend-to-backend, we use Tauri commands (covered in Part 2). On the frontend, command calls are wrapped in React Query mutation hooks. When a mutation like useCreateCharacter succeeds, it invalidates the relevant query cache, which triggers a refetch.

This gives us a belt-and-suspenders approach: the mutation invalidation handles the immediate response to user action, and the event listener handles asynchronous updates that originate from the backend (like zone changes from the log file).

Type safety across the boundary

I was nervous about keeping types synchronized between Rust and TypeScript. If the Rust side adds a new field to CharacterData and the TypeScript interface doesn't get updated, you get silent data loss.

The approach is manual – Serde serializes Rust structs to JSON using snake_case field names, and the TypeScript interfaces use the same naming. They have to match exactly. When I add a field on the Rust side, I go add it on the TypeScript side. When I rename something, I grep the frontend for the old name. It's not glamorous, but for the size of this project it works.

The full loop: log line to UI render

Let me trace one complete cycle. You're playing POE2, you walk into Clearfell, and the game writes a line to Client.txt:

  1. The log monitoring loop detects the file size has grown and reads the new lines
  2. The scene change parser extracts "Clearfell" from the log line
  3. The log analysis service processes it: looks up zone metadata, transitions the character, updates timestamps, persists to disk
  4. It publishes AppEvent::character_updated(...) to the event bus
  5. The Tauri event bridge receives the event and calls window.emit("character-updated", payload)
  6. The frontend's useAppEventListener hook fires the handler
  7. The handler does a functional state update
  8. React re-renders, and the player sees their current zone updated

That all happens in a few milliseconds. From the user's perspective, they walk into a new zone and the UI updates instantly.

Pitfalls I ran into

Stale closures in event handlers. Any time you're registering a callback that outlives the current render, be suspicious of every variable it captures. Use functional updates for state, or put changing values in the hook's dependency array.

Event ordering isn't guaranteed. tokio's broadcast channel preserves order per producer, but if multiple services publish concurrently, the bridge might receive them in a different order. In practice this hasn't been a problem because events that fire in close succession come from the same service.

Initial state vs event-driven state. Some contexts need to know the current state when they first mount, before any events fire. The GameProcessContext calls invoke("get_game_process_status") on mount to get the initial value, then listens for events after that.

What's next

That covers the event-driven communication layer – the event bus, the Tauri bridge, the frontend hooks, and the full loop in both directions. In Part 5 we'll shift to the frontend itself: TanStack Router for file-based routing, the provider dependency graph, query hook patterns, and component organization.

If you want to check out the project, head over to the Overlord project page. Thanks for reading and happy coding!

© 2025 David Meents. All Rights Reserved.