Tauriの基礎 - 型付きイベントとパターン

提供: MochiuWiki : SUSE, EC, PCB

概要

Tauriのイベントシステムを型安全に利用することで、RustバックエンドとReactフロントエンド間の通信におけるバグを事前に防止できる。

型付きイベントは、Rustの serde クレートによるシリアライゼーションと、TypeScriptの型システムを組み合わせて実現する。

型安全性の主なメリットは以下の通りである。

  • コンパイル時のエラー検出
    型の不一致やプロパティの誤字を開発時に発見できる。
    ランタイムエラーを減少させ、アプリケーションの安定性が向上する。
  • IDEの支援機能
    オートコンプリート、型推論、リファクタリング支援が有効になる。
    開発効率が大幅に向上する。
  • ドキュメントとしての役割
    型定義自体がAPI仕様のドキュメントとして機能する。
    チーム開発でのコミュニケーションコストを削減できる。
  • リファクタリングの安全性
    型定義を変更すると、影響範囲がコンパイルエラーとして明確になる。
    安全なコード変更が可能である。



型付きペイロードの定義

Rust側の型定義

Rustでイベントペイロードを型定義するには、serde::Serialize トレイトを導出する。

基本的な型定義の例を以下に示す。

 use serde::Serialize;
 
 // 基本的なイベントペイロード
 #[derive(Clone, Serialize)]
 pub struct DownloadStarted {
    pub url: String,
    pub download_id: u32,
    pub content_length: u64,
 }
 
 // 進捗通知用のペイロード
 #[derive(Clone, Serialize)]
 pub struct DownloadProgress {
    pub download_id: u32,
    pub bytes_downloaded: u64,
    pub percentage: f32,
 }
 
 // 完了通知用のペイロード
 #[derive(Clone, Serialize)]
 pub struct DownloadFinished {
    pub download_id: u32,
    pub success: bool,
    pub error_message: Option<String>,
 }


serde属性の活用

serde 属性を使用すると、フィールド名の変換や条件付きシリアライゼーションを制御できる。

 use serde::Serialize;
 
 #[derive(Clone, Serialize)]
 #[serde(rename_all = "camelCase")]  // フィールド名をcamelCaseに変換
 pub struct UserSession {
    pub user_id: u32,           // -> userId
    pub session_token: String,   // -> sessionToken
    pub expires_at: i64,         // -> expiresAt
 
    // 値がNoneの場合はフィールド自体を省略
    #[serde(skip_serializing_if = "Option::is_none")]
    pub refresh_token: Option<String>,
 }
 
 // タグ付きバリアント (判別可能なunion)
 #[derive(Clone, Serialize)]
 #[serde(tag = "type", rename_all = "camelCase")]
 pub enum FileEvent {
    Created { path: String, size: u64 },
    Modified { path: String, new_size: u64 },
    Deleted { path: String },
 }


TypeScript側の型定義

Rust側の型に対応するTypeScriptの型を定義する。

フィールド名は、Rust側で rename_all = "camelCase" を指定した場合、キャメルケース (camelCase) で記述する。

 // ダウンロードイベントの型定義
 interface DownloadStarted {
   url: string;
   downloadId: number;
   contentLength: number;
 }
 
 interface DownloadProgress {
   downloadId: number;
   bytesDownloaded: number;
   percentage: number;
 }
 
 interface DownloadFinished {
   downloadId: number;
   success: boolean;
   errorMessage?: string;  // オプション
 }
 
 // ユーザーセッションの型定義
 interface UserSession {
   userId: number;
   sessionToken: string;
   expiresAt: number;
   refreshToken?: string;  // オプション
 }
 
 // 判別可能なunion (tagged union)
 type FileEvent =
   | { type: 'created'; path: string; size: number }
   | { type: 'modified'; path: string; newSize: number }
   | { type: 'deleted'; path: string };


型定義の共有方法

RustとTypeScriptの型定義を手動で同期するのはエラーの原因となる。

以下に示す方法で型定義を共有することを推奨する。

  • 手動同期 (小規模プロジェクト向け)
    RustとTypeScriptで同じ構造を維持し、変更時に両方を更新する。
  • ts-rs クレートの使用
    Rustの型からTypeScriptの型定義を自動生成する。
  • OpenAPI/JSON Schemaの使用
    スキーマファーストで型を管理する。


ts-rsクレートの使用例を以下に示す。

 use serde::Serialize;
 use ts_rs::TS;
 
 // TSトレイトを導出するとTypeScriptの型定義が生成される
 #[derive(Clone, Serialize, TS)]
 #[ts(export)]  // 自動的にファイルに出力
 pub struct DownloadStarted {
    pub url: String,
    pub download_id: u32,
    pub content_length: u64,
 }



型安全なイベント送受信

型を指定したリッスン

listen 関数のジェネリクスを使用して、ペイロードの型を指定できる。

 import { listen, UnlistenFn } from '@tauri-apps/api/event';
 
 // 型を指定してリッスン
 async function setupDownloadListener(): Promise<UnlistenFn> {
   const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
     // event.payload は DownloadProgress 型として推論される
     console.log(`進捗: ${event.payload.percentage.toFixed(1)}%`);
     console.log(`ダウンロード済み: ${event.payload.bytesDownloadloaded} bytes`);
 
     // 型エラー: 存在しないプロパティへのアクセス
     // console.log(event.payload.invalidField);  // コンパイルエラー
   });
 
   return unlisten;
 }


Rustでの型付き送信

 use tauri::{AppHandle, Emitter};
 
 #[tauri::command]
 fn start_download(app: AppHandle, url: String) -> Result<(), String> {
    let download_id = 12345;
    let content_length = 1024000;
 
    // 型付きペイロードを送信
    app.emit("download-started", DownloadStarted {
       url,
       download_id,
       content_length,
    }).map_err(|e| e.to_string())?;
 
    Ok(())
 }



Reactでのイベント購読パターン

useEffectでの基本パターン

Reactコンポーネントでイベントを購読する場合、useEffect フック内でリスナーを登録し、クリーンアップ関数で解除する。

 import { useEffect, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 interface DownloadProgress {
   downloadId: number;
   percentage: number;
 }
 
 function DownloadIndicator() {
   const [progress, setProgress] = useState<number>(0);
 
   useEffect(() => {
     // 非同期関数を定義してリスナーを設定
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen<DownloadProgress>('download-progress', (event) => {
         setProgress(event.payload.percentage);
       });
     };
 
     setupListener();
 
     // クリーンアップ関数: コンポーネントのアンマウント時にリスナーを解除
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, []);  // 依存配列が空 = マウント時に1回だけ実行
 
   return (
     <div>
       <p>ダウンロード進捗: {progress.toFixed(1)}%</p>
     </div>
   );
 }


即時実行パターン

useEffect 内で非同期関数を直接使用せず、即時実行関数を使用するパターンも一般的である。

 import { useEffect, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 function StatusMonitor() {
   const [status, setStatus] = useState<string>('idle');
 
   useEffect(() => {
     // 即時実行非同期関数 (IIFE)
     const promise = (async () => {
       const unlisten = await listen<string>('status-changed', (event) => {
         setStatus(event.payload);
       });
 
       return unlisten;
     })();
 
     // クリーンアップ : Promise の結果を使ってリスナーを解除
     return () => {
       promise.then((unlisten) => unlisten());
     };
   }, []);
 
   return <div>ステータス: {status}</div>;
 }


複数イベントの購読

複数のイベントを購読する場合、全てのリスナーをクリーンアップする必要がある。

 import { useEffect, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 interface DownloadEvent {
   downloadId: number;
 }
 
 function DownloadManager() {
   const [downloads, setDownloads] = useState<Map<number, number>>(new Map());
 
   useEffect(() => {
     const unlisteners: (() => void)[] = [];
 
     const setupListeners = async () => {
       // 開始イベント
       const unlistenStart = await listen<DownloadEvent>('download-started', (event) => {
         setDownloads((prev) => {
           const next = new Map(prev);
           next.set(event.payload.downloadId, 0);
           return next;
         });
       });
       unlisteners.push(unlistenStart);
 
       // 進捗イベント
       const unlistenProgress = await listen<DownloadEvent & { percentage: number }>(
         'download-progress',
         (event) => {
           setDownloads((prev) => {
             const next = new Map(prev);
             next.set(event.payload.downloadId, event.payload.percentage);
             return next;
           });
         }
       );
       unlisteners.push(unlistenProgress);
 
       // 完了イベント
       const unlistenFinish = await listen<DownloadEvent>('download-finished', (event) => {
         setDownloads((prev) => {
           const next = new Map(prev);
           next.delete(event.payload.downloadId);
           return next;
         });
       });
       unlisteners.push(unlistenFinish);
     };
 
     setupListeners();
 
     // 全てのリスナーを解除
     return () => {
       unlisteners.forEach((unlisten) => unlisten());
     };
   }, []);
 
   return (
     <ul>
       {Array.from(downloads.entries()).map(([id, progress]) => (
         <li key={id}>ダウンロード {id}: {progress}%</li>
       ))}
     </ul>
   );
 }



カスタムフックの作成

基本的なカスタムフック

イベント購読をカプセル化したカスタムフックを作成すると、コンポーネント間でロジックを再利用できる。

 import { useEffect, useCallback } from 'react';
 import { listen, UnlistenFn } from '@tauri-apps/api/event';
 
 /**
  * Tauriイベントを購読するカスタムフック
  * @param eventName イベント名
  * @param handler イベントハンドラ
  */
 function useTauriEvent<T>(
   eventName: string,
   handler: (payload: T) => void
 ): void {
   useEffect(() => {
     let unlisten: UnlistenFn | null = null;
 
     const setupListener = async () => {
       unlisten = await listen<T>(eventName, (event) => {
         handler(event.payload);
       });
     };
 
     setupListener();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [eventName, handler]);
 }


使用例

 import { useState, useCallback } from 'react';
 
 interface Notification {
   title: string;
   message: string;
   timestamp: number;
 }
 
 function NotificationCenter() {
   const [notifications, setNotifications] = useState<Notification[]>([]);
 
   // イベントハンドラをuseCallbackでメモ化
   const handleNotification = useCallback((payload: Notification) => {
     setNotifications((prev) => [...prev, payload]);
   }, []);
 
   // カスタムフックを使用
   useTauriEvent<Notification>('notification-received', handleNotification);
 
   return (
     <div>
       <h2>通知</h2>
       <ul>
         {notifications.map((n, i) => (
           <li key={i}>
             <strong>{n.title}</strong>: {n.message}
           </li>
         ))}
       </ul>
     </div>
   );
 }


戻り値付きカスタムフック

イベントデータを状態として管理するカスタムフックの例を以下に示す。

 import { useState, useEffect, useCallback } from 'react';
 import { listen, UnlistenFn } from '@tauri-apps/api/event';
 
 /**
  * イベントデータを状態として管理するカスタムフック
  * @param eventName イベント名
  * @param initialValue 初期値
  * @returns 最新のイベントデータ
  */
 function useTauriEventState<T>(eventName: string, initialValue: T): T {
   const [data, setData] = useState<T>(initialValue);
 
   useEffect(() => {
     let unlisten: UnlistenFn | null = null;
 
     const setupListener = async () => {
       unlisten = await listen<T>(eventName, (event) => {
         setData(event.payload);
       });
     };
 
     setupListener();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [eventName]);
 
   return data;
 }
 
 // 使用例
 function ConnectionStatus() {
   // 最新の接続ステータスを取得
   const status = useTauriEventState<{ connected: boolean; latency: number }>(
     'connection-status',
     { connected: false, latency: 0 }
   );
 
   return (
     <div>
       ステータス: {status.connected ? '接続中' : '切断'}
       {status.connected && <span> (遅延: {status.latency}ms)</span>}
     </div>
   );
 }



メモリリーク防止

メモリリークの原因

Reactコンポーネントでイベントリスナーを適切に解除しないと、以下に示す問題が発生する。

  • メモリ使用量の増加
    アンマウントされたコンポーネントのリスナーが残り続け、メモリが解放されない。
  • 意図しない動作
    アンマウント済みコンポーネントの状態を更新しようとしてエラーが発生する。
  • パフォーマンス低下
    不要なリスナーがイベントを処理し続け、CPUリソースを消費する。


アンチパターン

 // 悪い例 : クリーンアップがない
 function BadComponent() {
   const [data, setData] = useState(null);
 
   useEffect(() => {
     // クリーンアップ関数を返していない!
     listen('data-updated', (event) => {
       setData(event.payload);
     });
   }, []);
 
   return <div>{JSON.stringify(data)}</div>;
 }
 // コンポーネントがアンマウントしてもリスナーが残り続ける


正しいパターン

 // 良い例 : 適切なクリーンアップ
 function GoodComponent() {
   const [data, setData] = useState(null);
 
   useEffect(() => {
     let unlisten: (() => void) | null = null;
 
     const setup = async () => {
       unlisten = await listen('data-updated', (event) => {
         setData(event.payload);
       });
     };
 
     setup();
 
     // クリーンアップ関数を返す
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, []);
 
   return <div>{JSON.stringify(data)}</div>;
 }


アンマウント後の状態更新エラー

コンポーネントがアンマウントされた後に状態を更新しようとすると、Reactが警告を出力する。

この問題を防ぐには、useRef でマウント状態を追跡する。

 import { useEffect, useRef, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 function SafeComponent() {
   const [data, setData] = useState(null);
   const isMounted = useRef(true);
 
   useEffect(() => {
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen('data-updated', (event) => {
         // マウント状態を確認してから状態を更新
         if (isMounted.current) {
           setData(event.payload);
         }
       });
     };
 
     setupListener();
 
     return () => {
       isMounted.current = false;  // アンマウントをマーク
       if (unlisten) {
         unlisten();
       }
     };
   }, []);
   
   return <div>{JSON.stringify(data)}</div>;
 }



推奨される事柄

イベント名の定数化

イベント名を文字列リテラルで直接記述すると、タイポの原因となる。

イベント名は定数として定義して、RustとTypeScriptで共有することを推奨する。

 // events.ts : イベント名の定数定義
 export const EVENT_NAMES = {
   DOWNLOAD_STARTED: 'download-started',
   DOWNLOAD_PROGRESS: 'download-progress',
   DOWNLOAD_FINISHED: 'download-finished',
   FILE_CHANGED: 'file-changed',
   USER_LOGGED_IN: 'user-logged-in',
 } as const;
 
 // 使用例
 import { EVENT_NAMES } from './events';
 
 listen(EVENT_NAMES.DOWNLOAD_PROGRESS, handler);


イベント型の集約

イベント名と型のマッピングを集約すると、型安全性がさらに向上する。

 // events.ts : イベント型の集約定義
 export interface EventMap {
   'download-started': DownloadStarted;
   'download-progress': DownloadProgress;
   'download-finished': DownloadFinished;
   'file-changed': FileEvent;
   'user-logged-in': UserSession;
 }
 
 // 型安全なリッスン関数
 export function listenTyped<K extends keyof EventMap>(
   eventName: K,
   handler: (payload: EventMap[K]) => void
 ): Promise<() => void> {
   return listen<EventMap[K]>(eventName, (event) => {
     handler(event.payload);
   });
 }
 
 // 使用例
 import { listenTyped } from './events';
 
 // イベント名から型が自動推論される
 listenTyped('download-progress', (payload) => {
   // payloadは、自動的にDownloadProgress型になる
   console.log(payload.percentage);
 });
 
 // 間違ったイベント名はコンパイルエラー
 // listenTyped('invalid-event', handler);  // エラー


エラー処理の実装

イベント処理でエラーが発生した場合、アプリケーション全体に影響しないように適切に処理する。

 import { useEffect, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 function RobustComponent() {
   const [data, setData] = useState(null);
   const [error, setError] = useState<string | null>(null);
 
   useEffect(() => {
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen('data-updated', async (event) => {
         try {
           // イベント処理
           setError(null);
           setData(event.payload);
         }
         catch (err) {
           // エラーをキャッチして状態に反映
           setError(err instanceof Error ? err.message : '不明なエラー');
           console.error('イベント処理エラー:', err);
         }
       });
     };
 
     setupListener().catch((err) => {
       // リスナー設定自体のエラー
       setError('リスナーの設定に失敗しました');
       console.error('リスナー設定エラー:', err);
     });
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, []);
 
   if (error) {
     return <div className="error">エラー: {error}</div>;
   }
 
   return <div>{JSON.stringify(data)}</div>;
 }


条件付きイベント購読

特定の条件下でのみイベントを購読するパターンを以下に示す。

 import { useEffect, useState } from 'react';
 import { listen } from '@tauri-apps/api/event';
 
 function ConditionalListener() {
   const [isActive, setIsActive] = useState(false);
 
   useEffect(() => {
     // アクティブでない場合は何もしない
     if (!isActive) {
       return;
     }
 
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen('status-update', (event) => {
         console.log('ステータス更新:', event.payload);
       });
     };
 
     setupListener();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [isActive]);  // isActive が変更されたら再設定
 
   return (
     <button onClick={() => setIsActive(!isActive)}>
       {isActive ? '無効化' : '有効化'}
     </button>
   );
 }



サンプルコード

ファイルウォッチャー

ファイルシステムの変更を監視し、Reactコンポーネントに通知する例を以下に示す。

Rust (バックエンド)
 use serde::Serialize;
 use tauri::{AppHandle, Emitter};
 use std::collections::HashMap;
 
 #[derive(Clone, Serialize)]
 #[serde(tag = "type", rename_all = "camelCase")]
 pub enum FileChange {
    Created { path: String },
    Modified { path: String, size: u64 },
    Deleted { path: String },
 }
 
 #[tauri::command]
 fn start_file_watcher(app: AppHandle, watch_path: String) -> Result<(), String> {
    // 実際のファイル監視は notify クレート等を使用
    // ここではシミュレーション
 
    // ファイル作成イベント
    app.emit("file-change", FileChange::Created {
       path: format!("{}/new_file.txt", watch_path),
    }).map_err(|e| e.to_string())?;
 
    // ファイル変更イベント
    app.emit("file-change", FileChange::Modified {
       path: format!("{}/existing_file.txt", watch_path),
       size: 1024,
    }).map_err(|e| e.to_string())?;
 
    Ok(())
 }


TypeScript (フロントエンド)
 import { useEffect, useState, useCallback } from 'react';
 import { listen } from '@tauri-apps/api/event';
 import { invoke } from '@tauri-apps/api/core';
 
 // ファイル変更イベントの型
 type FileChange =
   | { type: 'created'; path: string }
   | { type: 'modified'; path: string; size: number }
   | { type: 'deleted'; path: string };
 
 // ファイル情報の型
 interface FileInfo {
   path: string;
   status: 'created' | 'modified' | 'deleted';
   size?: number;
   timestamp: Date;
 }
 
 function FileWatcher({ watchPath }: { watchPath: string }) {
   const [files, setFiles] = useState<FileInfo[]>([]);
 
   // ファイル変更ハンドラ
   const handleFileChange = useCallback((change: FileChange) => {
     const fileInfo: FileInfo = {
       path: change.path,
       status: change.type,
       size: 'size' in change ? change.size : undefined,
       timestamp: new Date(),
     };
 
     setFiles((prev) => [fileInfo, ...prev].slice(0, 100));  // 最新100件
   }, []);
 
   // イベントリスナーの設定
   useEffect(() => {
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen<FileChange>('file-change', (event) => {
         handleFileChange(event.payload);
       });
     };
 
     setupListener();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [handleFileChange]);
 
   // ウォッチャーの開始
   useEffect(() => {
     invoke('start_file_watcher', { watchPath }).catch(console.error);
   }, [watchPath]);
 
   return (
     <div>
       <h2>ファイル変更履歴</h2>
       <ul>
         {files.map((file, index) => (
           <li key={index}>
             <span className="status">{file.status}</span>
             <span className="path">{file.path}</span>
             {file.size && <span className="size">({file.size} bytes)</span>}
             <span className="time">{file.timestamp.toLocaleTimeString()}</span>
           </li>
         ))}
       </ul>
     </div>
   );
 }


リアルタイムチャット

チャットメッセージの送受信をイベントシステムで実装する例を以下に示す。

 import { useEffect, useState, useCallback, useRef } from 'react';
 import { listen } from '@tauri-apps/api/event';
 import { invoke } from '@tauri-apps/api/core';
 
 interface ChatMessage {
   id: string;
   sender: string;
   content: string;
   timestamp: number;
 }
 
 function ChatRoom({ roomId, username }: { roomId: string; username: string }) {
   const [messages, setMessages] = useState<ChatMessage[]>([]);
   const [input, setInput] = useState('');
   const messagesEndRef = useRef<HTMLDivElement>(null);
   
   // メッセージ受信ハンドラ
   const handleMessage = useCallback((message: ChatMessage) => {
     setMessages((prev) => [...prev, message]);
   }, []);
 
   // イベントリスナーの設定
   useEffect(() => {
     let unlisten: (() => void) | null = null;
 
     const setupListener = async () => {
       unlisten = await listen<ChatMessage>('chat-message', (event) => {
         handleMessage(event.payload);
       });
     };
 
     setupListener();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [handleMessage]);
 
   // 自動スクロール
   useEffect(() => {
     messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
   }, [messages]);
 
   // メッセージ送信
   const sendMessage = async () => {
     if (!input.trim()) return;
 
     await invoke('send_chat_message', {
       roomId,
       sender: username,
       content: input.trim(),
     });
 
     setInput('');
   };
 
   return (
     <div className="chat-room">
       <div className="messages">
         {messages.map((msg) => (
           <div key={msg.id} className={`message ${msg.sender === username ? 'own' : ''}`}>
             <span className="sender">{msg.sender}</span>
             <span className="content">{msg.content}</span>
             <span className="time">
               {new Date(msg.timestamp).toLocaleTimeString()}
             </span>
           </div>
         ))}
         <div ref={messagesEndRef} />
       </div>
       <div className="input-area">
         <input
           type="text"
           value={input}
           onChange={(e) => setInput(e.target.value)}
           onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
           placeholder="メッセージを入力..."
         />
         <button onClick={sendMessage}>送信</button>
       </div>
     </div>
   );
 }



デバッグとトラブルシューティング

イベントのロギング

開発時にイベントの送受信をロギングすると、デバッグが容易になる。

 // デバッグ用 : 全てのイベントをロギング
 import { listenAny } from '@tauri-apps/api/event';
 
 async function enableEventLogging() {
   const unlisten = await listenAny((event) => {
     console.log(`[Event] ${event.event}`, event.payload);
   });
 
   return unlisten;
 }
 
 // アプリケーション初期化時に有効化
 enableEventLogging();


よくある問題と解決策

トラブルシューティング
問題 原因 解決策
イベントが受信されない リスナーが登録されていない、またはイベント名が間違っている イベント名を確認し、listenAny でイベントが送信されているか確認する。
型エラーが発生する RustとTypeScriptの型定義が不一致 rename_all 属性を確認し、フィールド名が一致しているか確認する。
メモリ使用量が増加 リスナーが解除されていない useEffect のクリーンアップ関数で unlisten を呼び出す。
アンマウント後にエラー アンマウント後の状態更新 isMounted フラグで状態更新をガードする。
高頻度イベントで遅延 イベント処理が重い スロットリングやデバウンスを定義する。



参考リンク