Tauriの基礎 - React連携パターン

提供: MochiuWiki : SUSE, EC, PCB

概要

TauriとReactの連携は、RustバックエンドとWebフロントエンド間のIPC (プロセス間通信) を通じて実現される。

フロントエンドからは invoke 関数を使用してRustで定義されたコマンドを呼び出し、バックエンドからはイベントシステムを使用してフロントエンドに通知を送信できる。

React連携における主な考慮事項は以下の通りである。

  • 非同期処理の管理
    Tauriコマンドは非同期で実行されるため、async / awaitとReactの状態管理を適切に連携させる必要がある。
  • ライフサイクル管理
    コンポーネントのマウント / アンマウントに合わせてイベントリスナーを登録 / 解除し、メモリリークを防止する必要がある。
  • 型安全性
    TypeScriptを使用して、RustとTypeScript間で型を整合させ、ランタイムエラーを防止する。
  • カスタムHookの活用
    Tauriコマンド呼び出しをカスタムHookでカプセル化して、再利用性と保守性を向上させる。



invokeの基本

invoke 関数は、フロントエンドからRustバックエンドのコマンドを呼び出すためのAPIである。

Rustコマンドの定義

まず、Rust側で #[tauri::command] 属性を使用してコマンドを定義する。

 // src-tauri/src/lib.rs
 use serde::{Deserialize, Serialize};
 
 // カスタム型の定義 (serdeでシリアライズ/デシリアライズ)
 #[derive(Debug, Serialize, Deserialize)]
 pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
 }
 
 // 同期コマンド
 #[tauri::command]
 fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
 }
 
 // 非同期コマンド
 #[tauri::command]
 async fn get_user(id: u32) -> Result<User, String> {
    // データベースやAPIからの取得処理を想定
    Ok(User {
       id,
       name: "John Doe".to_string(),
       email: "john@example.com".to_string(),
    })
 }
 
 // 複数引数のコマンド
 #[tauri::command]
 fn calculate(a: i32, b: i32, operation: String) -> Result<i32, String> {
    match operation.as_str() {
       "add" => Ok(a + b),
       "subtract" => Ok(a - b),
       "multiply" => Ok(a * b),
       _ => Err(format!("Unknown operation: {}", operation)),
    }
 }
 
 // コマンドを登録
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    tauri::Builder::default()
       .invoke_handler(tauri::generate_handler![
          greet,
          get_user,
          calculate
       ])
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


TypeScriptからの呼び出し

フロントエンドから invoke 関数を使用してコマンドを呼び出す。

 // src/api/commands.ts
 import { invoke } from '@tauri-apps/api/core';
 
 // 型定義 (Rust側の型と整合させる)
 interface User {
   id: number;
   name: string;
   email: string;
 }
 
 // 基本的な呼び出し
 export async function greet(name: string): Promise<string> {
   return await invoke('greet', { name });
 }
 
 // ジェネリクスで戻り値の型を指定
 export async function getUser(id: number): Promise<User> {
   return await invoke<User>('get_user', { id });
 }
 
 // エラーハンドリングを含む呼び出し
 export async function calculate(
   a: number,
   b: number,
   operation: string
 ): Promise<number> {
   try {
     return await invoke<number>('calculate', { a, b, operation });
   }
   catch (error) {
     const message = error instanceof Error ? error.message : String(error);
     throw new Error(`計算エラー: ${message}`);
   }
 }


エラーハンドリング

Rust側で Result<T, E> を返すコマンドは、エラー時にTypeScript側で例外としてスローされる。

 // src/api/commands.ts
 import { invoke } from '@tauri-apps/api/core';
 
 // カスタムエラークラス
 export class TauriCommandError extends Error {
   constructor(
     public command: string,
     message: string
   ) {
     super(`Command "${command}" failed: ${message}`);
     this.name = 'TauriCommandError';
   }
 }
 
 // エラーハンドリング用ラッパー関数
 export async function safeInvoke<T>(
   command: string,
   args: Record<string, unknown> = {}
 ): Promise<T> {
   try {
     return await invoke<T>(command, args);
   }
   catch (error) {
     // Tauriのエラーオブジェクトを文字列に変換
     const message = typeof error === 'string'
       ? error
       : error instanceof Error
         ? error.message
         : JSON.stringify(error);
 
     throw new TauriCommandError(command, message);
   }
 }



useEffectでのinvoke呼び出し

Reactコンポーネントで invoke を使用する場合は、useEffect と組み合わせてライフサイクル管理を行う。

マウント時実行パターン

コンポーネントのマウント時にデータを取得する基本的なパターンを以下に示す。

 // src/components/UserProfile.tsx
 import { useState, useEffect } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 interface User {
   id: number;
   name: string;
   email: string;
 }
 
 export function UserProfile({ userId }: { userId: number }) {
   const [user, setUser] = useState<User | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
 
   useEffect(() => {
     // 非同期関数を定義して即時実行
     const fetchUser = async () => {
       setLoading(true);
       setError(null);
 
       try {
         const result = await invoke<User>('get_user', { id: userId });
         setUser(result);
       }
       catch (err) {
         const message = err instanceof Error ? err.message : String(err);
         setError(message);
       }
       finally {
         setLoading(false);
       }
     };
 
     fetchUser();
 
     // クリーンアップ関数 (必要に応じて)
     return () => {
       // 非同期処理のキャンセル等があればここで実施
     };
   }, [userId]); // userIdが変更されたら再実行
 
   if (loading) return <div>読み込み中...</div>;
   if (error) return <div>エラー: {error}</div>;
   if (!user) return null;
 
   return (
     <div className="user-profile">
       <h2>{user.name}</h2>
       <p>Email: {user.email}</p>
     </div>
   );
 }


無限ループの防止

useEffect の依存配列に注意しないと、無限ループが発生する可能性がある。

 // src/components/Settings.tsx
 import { useState, useEffect, useRef } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 interface Settings {
   theme: string;
   language: string;
 }
 
 export function SettingsPanel() {
   const [settings, setSettings] = useState<Settings | null>(null);
   const [loading, setLoading] = useState(true);
 
   // 初期化済みフラグ (無限ループ防止)
   const isInitialized = useRef(false);
 
   useEffect(() => {
     // 既に初期化済みの場合はスキップ
     if (isInitialized.current) return;
     isInitialized.current = true;
 
     const loadSettings = async () => {
       try {
         const result = await invoke<Settings>('get_settings');
         setSettings(result);
       }
       catch (error) {
         console.error('設定の読み込みに失敗:', error);
       }
       finally {
         setLoading(false);
       }
     };
 
     loadSettings();
   }, []); // 空の依存配列 = マウント時のみ実行

   if (loading) return <div>読み込み中...</div>;
 
   return (
     <div className="settings-panel">
       <h2>設定</h2>
       <pre>{JSON.stringify(settings, null, 2)}</pre>
     </div>
   );
 }


依存配列の注意点

useEffect の依存配列にオブジェクトや関数を含める場合は注意が必要である。

 // src/components/SearchPanel.tsx
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 interface SearchResult {
   id: number;
   title: string;
 }
 
 export function SearchPanel() {
   const [query, setQuery] = useState('');
   const [results, setResults] = useState<SearchResult[]>([]);
   const [searching, setSearching] = useState(false);
 
   // 検索関数をuseCallbackでメモ化
   const search = useCallback(async (searchQuery: string) => {
     if (!searchQuery.trim()) {
       setResults([]);
       return;
     }
 
     setSearching(true);
     try {
       const items = await invoke<SearchResult[]>('search', {
         query: searchQuery,
       });
       setResults(items);
     }
     catch (error) {
       console.error('検索エラー:', error);
       setResults([]);
     }
     finally {
       setSearching(false);
     }
   }, []); // 依存なし = 関数は再作成されない
 
   // クエリ変更時に検索実行 (デバウンス処理)
   useEffect(() => {
     const timer = setTimeout(() => {
       search(query);
     }, 300); // 300ms デバウンス
 
     return () => clearTimeout(timer);
   }, [query, search]); // searchはメモ化されているので安全
 
   return (
     <div>
       <input
         type="text"
         value={query}
         onChange={(e) => setQuery(e.target.value)}
         placeholder="検索..."
       />
       {searching && <span>検索中...</span>}
       <ul>
         {results.map((item) => (
           <li key={item.id}>{item.title}</li>
         ))}
       </ul>
     </div>
   );
 }



カスタムフックの作成

Tauriコマンド呼び出しをカプセル化したカスタムフックを作成することにより、ソースコードの再利用性と保守性が向上する。

useTauriCommandフック

汎用的なTauriコマンド呼び出しフックの例を以下に示す。

 // src/hooks/useTauriCommand.ts
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 // Hookの戻り値の型
 interface UseTauriCommandResult<T, Args extends Record<string, unknown>> {
   data: T | null;
   error: string | null;
   loading: boolean;
   execute: (args: Args) => Promise<T | null>;
   reset: () => void;
 }
 
 // 汎用TauriコマンドHook
 export function useTauriCommand<T, Args extends Record<string, unknown> = Record<string, unknown>>(
   command: string
 ): UseTauriCommandResult<T, Args> {
   const [data, setData] = useState<T | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(false);
 
   // アンマウント時のクリーンアップ用
   const isMounted = useRef(true);
 
   useEffect(() => {
     isMounted.current = true;
     return () => {
       isMounted.current = false;
     };
   }, []);
 
   // コマンド実行関数
   const execute = useCallback(async (args: Args): Promise<T | null> => {
     setLoading(true);
     setError(null);
 
     try {
       const result = await invoke<T>(command, args);
 
       // アンマウント後は状態を更新しない
       if (isMounted.current) {
         setData(result);
       }
 
       return result;
     }
     catch (err) {
       const message = typeof err === 'string'
         ? err
         : err instanceof Error
           ? err.message
           : 'Unknown error';
 
       if (isMounted.current) {
         setError(message);
       }
 
       return null;
     }
     finally {
       if (isMounted.current) {
         setLoading(false);
       }
     }
   }, [command]);
 
   // 状態リセット関数
   const reset = useCallback(() => {
     setData(null);
     setError(null);
     setLoading(false);
   }, []);
 
   return { data, error, loading, execute, reset };
 }


使用例

useTauriCommand フックの例を以下に示す。

 // src/components/FileList.tsx
 import { useTauriCommand } from '../hooks/useTauriCommand';
 
 interface FileInfo {
   name: string;
   size: number;
   modified: string;
 }
 
 interface ListFilesArgs {
   directory: string;
   pattern?: string;
 }
 
 export function FileList() {
   const {
     data: files,
     error,
     loading,
     execute,
   } = useTauriCommand<FileInfo[], ListFilesArgs>('list_files');
 
   const handleListFiles = async (directory: string) => {
     await execute({ directory, pattern: '*.txt' });
   };
 
   return (
     <div>
       <button
         onClick={() => handleListFiles('/home/user/documents')}
         disabled={loading}
       >
         {loading ? '読み込み中...' : 'ファイル一覧を取得'}
       </button>
 
       {error && (
         <div className="error">エラー: {error}</div>
       )}
 
       {files && (
         <ul>
           {files.map((file, index) => (
             <li key={index}>
               <strong>{file.name}</strong>
               <span> - {file.size} bytes</span>
             </li>
           ))}
         </ul>
       )}
     </div>
   );
 }


自動実行フック

マウント時に自動でコマンドを実行するフックの例を以下に示す。

 // src/hooks/useTauriQuery.ts
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 interface UseTauriQueryResult<T> {
   data: T | null;
   error: string | null;
   loading: boolean;
   refetch: () => Promise<void>;
 }
 
 // 自動実行クエリHook
 export function useTauriQuery<T, Args extends Record<string, unknown>>(
   command: string,
   args: Args,
   options: {
     enabled?: boolean;      // 自動実行を有効にするか
     refetchInterval?: number; // 定期再取得間隔 (ms)
   } = {}
 ): UseTauriQueryResult<T> {
   const { enabled = true, refetchInterval } = options;
 
   const [data, setData] = useState<T | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(false);
 
   const isMounted = useRef(true);
 
   useEffect(() => {
     isMounted.current = true;
     return () => {
       isMounted.current = false;
     };
   }, []);
 
   // データ取得関数
   const fetchData = useCallback(async () => {
     if (!enabled) return;
 
     setLoading(true);
     setError(null);
 
     try {
       const result = await invoke<T>(command, args);
 
       if (isMounted.current) {
         setData(result);
       }
     }
     catch (err) {
       const message = typeof err === 'string'
         ? err
         : err instanceof Error
           ? err.message
           : 'Unknown error';
 
       if (isMounted.current) {
         setError(message);
       }
     }
     finally {
       if (isMounted.current) {
         setLoading(false);
       }
     }
   }, [command, JSON.stringify(args), enabled]);
 
   // 初回実行
   useEffect(() => {
     fetchData();
   }, [fetchData]);
 
   // 定期再取得
   useEffect(() => {
     if (!refetchInterval) return;
 
     const interval = setInterval(fetchData, refetchInterval);
     return () => clearInterval(interval);
   }, [fetchData, refetchInterval]);
 
   return {
     data,
     error,
     loading,
     refetch: fetchData,
   };
 }


自動実行フックの使用例

 // src/components/SystemStatus.tsx
 import { useTauriQuery } from '../hooks/useTauriQuery';
 
 interface SystemInfo {
   cpu_usage: number;
   memory_usage: number;
   uptime: number;
 }
 
 export function SystemStatus() {
   // 5秒ごとに自動更新
   const { data, loading, error, refetch } = useTauriQuery<SystemInfo, {}>(
     'get_system_info',
     {},
     {
       enabled: true,
       refetchInterval: 5000,
     }
   );
 
   if (error) {
     return <div className="error">エラー: {error}</div>;
   }
 
   return (
     <div className="system-status">
       <h2>システム状態</h2>
 
       {loading && !data && <div>読み込み中...</div>}
 
       {data && (
         <div>
           <div>CPU使用率: {data.cpu_usage.toFixed(1)}%</div>
           <div>メモリ使用率: {data.memory_usage.toFixed(1)}%</div>
           <div>稼働時間: {Math.floor(data.uptime / 3600)}時間</div>
         </div>
       )}
 
       <button onClick={refetch} disabled={loading}>
         更新
       </button>
     </div>
   );
 }



イベントリスナーの管理

Tauriのイベントシステムを使用して、Rustバックエンドからフロントエンドに通知を送信できる。

listenの使用

listen 関数を使用してイベントを購読する。

 // src-tauri/src/lib.rs
 use tauri::{AppHandle, Emitter};
 use serde::Serialize;
 
 // イベントペイロードの型定義
 #[derive(Clone, Serialize)]
 #[serde(rename_all = "camelCase")]
 struct DownloadProgress {
     download_id: usize,
     progress: u32,
     total: u32,
 }
 
 #[tauri::command]
 async fn start_download(app: AppHandle, url: String) -> Result<(), String> {
    // ダウンロード開始イベント
    app.emit("download-started", &url)
       .map_err(|e| e.to_string())?;
 
    // 進捗イベント (例: 25%, 50%, 75%, 100%)
    for progress in [25, 50, 75, 100] {
       app.emit("download-progress", DownloadProgress {
          download_id: 1,
          progress,
          total: 100,
       }).map_err(|e| e.to_string())?;
    }
 
    // 完了イベント
    app.emit("download-finished", &url)
       .map_err(|e| e.to_string())?;
 
    Ok(())
 }


TypeScript側でイベントを購読する。

 // src/components/DownloadManager.tsx
 import { useEffect, useState } from 'react';
 import { listen, UnlistenFn } from '@tauri-apps/api/event';
 
 // イベントペイロードの型定義
 interface DownloadProgress {
   downloadId: number;
   progress: number;
   total: number;
 }
 
 export function DownloadManager() {
   const [progress, setProgress] = useState(0);
   const [status, setStatus] = useState<'idle' | 'downloading' | 'completed'>('idle');
 
   useEffect(() => {
     // イベントリスナーを登録
     const unlistenPromise = Promise.all([
       // ダウンロード開始イベント
       listen<string>('download-started', (event) => {
         console.log('ダウンロード開始:', event.payload);
         setStatus('downloading');
         setProgress(0);
       }),
 
       // 進捗イベント
       listen<DownloadProgress>('download-progress', (event) => {
         const { progress, total } = event.payload;
         setProgress(progress);
         console.log(`進捗: ${progress}/${total}%`);
       }),
 
       // 完了イベント
       listen<string>('download-finished', (event) => {
         console.log('ダウンロード完了:', event.payload);
         setStatus('completed');
         setProgress(100);
       }),
     ]);
 
     // クリーンアップ: 全てのリスナーを解除
     return () => {
       unlistenPromise.then((unlisteners) => {
         unlisteners.forEach((unlisten) => unlisten());
       });
     };
   }, []); // 空の依存配列 = マウント時のみ登録
 
   return (
     <div>
       <h2>ダウンロード状態</h2>
       <div>ステータス: {status}</div>
       {status === 'downloading' && (
         <div className="progress-bar">
           <div style={{ width: `${progress}%` }} />
           <span>{progress}%</span>
         </div>
       )}
     </div>
   );
 }


カスタムイベントHook

イベントリスナー管理をカプセル化したカスタムフックの例を以下に示す。

 // src/hooks/useTauriEvent.ts
 import { useEffect, useRef } from 'react';
 import { listen, UnlistenFn } from '@tauri-apps/api/event';
 
 // 汎用イベントHook
 export function useTauriEvent<T>(
   eventName: string,
   callback: (payload: T) => void
 ) {
   const callbackRef = useRef(callback);
 
   // コールバックを最新に保つ
   useEffect(() => {
     callbackRef.current = callback;
   }, [callback]);
 
   useEffect(() => {
     let unlisten: UnlistenFn | undefined;
 
     // イベントリスナーを登録
     const setupListener = async () => {
       unlisten = await listen<T>(eventName, (event) => {
         callbackRef.current(event.payload);
       });
     };
 
     setupListener();
 
     // クリーンアップ
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [eventName]); // eventNameが変更されたら再登録
 }
 
 // 複数イベント用フック
 export function useTauriEvents<T extends Record<string, unknown>>(
   events: { [K in keyof T]: string },
   callbacks: { [K in keyof T]: (payload: T[K]) => void }
 ) {
   useEffect(() => {
     const unlisteners: Promise<UnlistenFn>[] = [];
 
     for (const key of Object.keys(events) as (keyof T)[]) {
       const eventName = events[key];
       const callback = callbacks[key];
 
       const unlistenPromise = listen(eventName, (event) => {
         callback(event.payload as T[typeof key]);
       });
 
       unlisteners.push(unlistenPromise);
     }
 
     return () => {
       Promise.all(unlisteners).then((fns) => {
         fns.forEach((unlisten) => unlisten());
       });
     };
   }, []);
 }


カスタムイベントフックの使用例

 // src/components/NotificationHandler.tsx
 import { useTauriEvent } from '../hooks/useTauriEvent';
 
 interface NotificationPayload {
   title: string;
   message: string;
   type: 'info' | 'warning' | 'error';
 }
 
 export function NotificationHandler() {
   const [notifications, setNotifications] = useState<NotificationPayload[]>([]);
 
   // 通知イベントを購読
   useTauriEvent<NotificationPayload>('notification', (payload) => {
     setNotifications((prev) => [...prev, payload]);
 
     // 5秒後に自動削除
     setTimeout(() => {
       setNotifications((prev) => prev.filter((n) => n !== payload));
     }, 5000);
   });
 
   return (
     <div className="notifications">
       {notifications.map((notification, index) => (
         <div
           key={index}
           className={`notification notification-${notification.type}`}
         >
           <strong>{notification.title}</strong>
           <p>{notification.message}</p>
         </div>
       ))}
     </div>
   );
 }



TypeScript型定義

TauriとReact / TypeScriptの連携において、型安全性を確保することは重要である。

コマンド引数の型

Rustコマンドの引数は、serde::Deserialize を定義した構造体で定義できる。

 // src-tauri/src/lib.rs
 use serde::{Deserialize, Serialize};
 
 // コマンド引数の構造体
 #[derive(Debug, Deserialize)]
 pub struct CreateFileArgs {
    pub path: String,
    pub content: String,
    pub overwrite: bool,
 }
 
 // 戻り値の構造体
 #[derive(Debug, Serialize)]
 pub struct CreateFileResult {
     pub success: bool,
     pub bytes_written: usize,
     pub message: String,
 }
 
 #[tauri::command]
 fn create_file(args: CreateFileArgs) -> Result<CreateFileResult, String> {
    // ファイル作成処理
    Ok(CreateFileResult {
       success: true,
       bytes_written: args.content.len(),
       message: format!("Created: {}", args.path),
    })
 }


TypeScript側でも対応する型を定義する。

 // src/types/commands.ts
 
 // コマンド引数の型 (RustのCreateFileArgsに対応)
 export interface CreateFileArgs {
   path: string;
   content: string;
   overwrite: boolean;
 }
 
 // 戻り値の型 (RustのCreateFileResultに対応)
 export interface CreateFileResult {
   success: boolean;
   bytesWritten: number;  // camelCaseに変換される
   message: string;
 }
 
 // 型安全なinvokeラッパー
 import { invoke } from '@tauri-apps/api/core';
 
 export async function createFile(args: CreateFileArgs): Promise<CreateFileResult> {
   return await invoke<CreateFileResult>('create_file', { args });
 }


serdeとの互換性

Rustのserdeはデフォルトでsnake_caseを使用し、TypeScriptではキャメルケース (camelCase) が一般的である。
#[serde(rename_all = "camelCase")] 属性を使用して、自動的に変換できる。

 // Rust側
 #[derive(Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct UserSettings {
    pub theme_color: String,      // themeColor に変換
    pub font_size: u32,           // fontSize に変換
    pub auto_save_enabled: bool,  // autoSaveEnabled に変換
 }


 // TypeScript側 (camelCase)
 export interface UserSettings {
   themeColor: string;
   fontSize: number;
   autoSaveEnabled: boolean;
 }


ジェネリクス活用

汎用的なコマンド呼び出し関数でジェネリクスを活用する例を以下に示す。

 // src/api/tauriClient.ts
 import { invoke } from '@tauri-apps/api/core';
 
 // コマンド定義の型
 type CommandArgs = Record<string, unknown>;
 
 // 型安全なAPIクライアント
 export class TauriClient {
   // GET的なコマンド (引数なし、戻り値あり)
   static async query<T>(command: string): Promise<T> {
     return await invoke<T>(command);
   }
 
   // GET的なコマンド (引数あり、戻り値あり)
   static async queryWithArgs<T, A extends CommandArgs>(
     command: string,
     args: A
   ): Promise<T> {
     return await invoke<T>(command, args);
   }
 
   // MUTATION的なコマンド (戻り値なし)
   static async mutate<A extends CommandArgs>(
     command: string,
     args: A
   ): Promise<void> {
     await invoke(command, args);
   }
 
   // MUTATION的なコマンド (戻り値あり)
   static async mutateWithResult<T, A extends CommandArgs>(
     command: string,
     args: A
   ): Promise<T> {
     return await invoke<T>(command, args);
   }
 }
 
 // 使用例
 interface User {
   id: number;
   name: string;
 }
 
 interface SearchArgs {
   query: string;
   limit: number;
 }
 
 // クエリ実行
 const users = await TauriClient.queryWithResult<User[], SearchArgs>(
   'search_users',
   { query: 'john', limit: 10 }
 );



tauri-spectaによる型自動生成

tauri-spectaライブラリを使用すると、RustコードからTypeScriptの型定義を自動生成できる。

セットアップ

まず、依存関係を追加する。

# spectaとtauri-spectaを追加
cargo add specta
cargo add tauri-specta --features javascript,typescript


spectaアノテーションの追加

コマンドと型に specta::Type を追加する。

 // src-tauri/src/lib.rs
 use serde::{Deserialize, Serialize};
 use specta::Type;
 use tauri_specta::{ts, collect_types};
 
 // カスタム型にSpectaサポートを追加
 #[derive(Serialize, Deserialize, Type)]
 #[serde(rename_all = "camelCase")]
 pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
    pub created_at: String,
 }
 
 #[derive(Deserialize, Type)]
 #[serde(rename_all = "camelCase")]
 pub struct CreateUserArgs {
    pub name: String,
    pub email: String,
 }
 
 // コマンドにspectaアノテーションを追加
 #[tauri::command]
 #[specta::specta]
 fn get_user(id: u32) -> Result<User, String> {
    Ok(User {
       id,
       name: "John Doe".to_string(),
       email: "john@example.com".to_string(),
       created_at: "2025-01-01".to_string(),
    })
 }
 
 #[tauri::command]
 #[specta::specta]
 async fn create_user(args: CreateUserArgs) -> Result<User, String> {
    Ok(User {
       id: 1,
       name: args.name,
       email: args.email,
       created_at: "2025-01-15".to_string(),
    })
 }
 
 #[tauri::command]
 #[specta::specta]
 fn list_users() -> Vec<User> {
    vec![
       User {
          id: 1,
          name: "Alice".to_string(),
          email: "alice@example.com".to_string(),
          created_at: "2025-01-01".to_string(),
       },
    ]
 }
 
 // コマンド登録
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    tauri::Builder::default()
       .invoke_handler(tauri::generate_handler![
          get_user,
          create_user,
          list_users
       ])
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }
 
 // デバッグビルド時にTypeScript型を生成
 #[cfg(debug_assertions)]
 fn export_types() {
    ts::export(
       collect_types![get_user, create_user, list_users],
       "../src/bindings.ts"
    ).unwrap();
 }
 
 // ビルド時に型をエクスポート
 #[cfg(debug_assertions)]
 #[tauri::command]
 fn __export_types() {
    export_types();
 }


生成されたTypeScriptバインディング

src/bindings.ts ファイルに以下に示すような型定義が生成される。

 // src/bindings.ts (自動生成)
 // このファイルはRustコードから自動生成されています。手動で編集しないでください。
 
 import { invoke } from "@tauri-apps/api/core";
 
 export interface User {
   id: number;
   name: string;
   email: string;
   createdAt: string;
 }
 
 export interface CreateUserArgs {
   name: string;
   email: string;
 }
 
 export async function getUser(id: number): Promise<User> {
   return await invoke<User>("get_user", { id });
 }
 
 export async function createUser(args: CreateUserArgs): Promise<User> {
   return await invoke<User>("create_user", { args });
 }
 
 export async function listUsers(): Promise<User[]> {
   return await invoke<User[]>("list_users");
 }


フロントエンドでの使用

生成されたバインディングをインポートして使用する。

 // src/components/UserManager.tsx
 import { useState, useEffect } from 'react';
 // 自動生成されたバインディングをインポート
 import { getUser, createUser, listUsers, User } from '../bindings';
 
 export function UserManager() {
   const [users, setUsers] = useState<User[]>([]);
   const [loading, setLoading] = useState(false);
 
   // ユーザ一覧を取得
   const fetchUsers = async () => {
     setLoading(true);
     try {
       const result = await listUsers();  // 型安全!
       setUsers(result);
     }
     catch (error) {
       console.error('ユーザ取得エラー:', error);
     }
     finally {
       setLoading(false);
     }
   };
 
   useEffect(() => {
     fetchUsers();
   }, []);
 
   // 新規ユーザ作成
   const handleCreateUser = async () => {
     try {
       const newUser = await createUser({
         name: 'New User',
         email: 'new@example.com',
       });
       console.log('作成されたユーザ:', newUser);
       fetchUsers();  // 一覧を更新
     }
     catch (error) {
       console.error('ユーザ作成エラー:', error);
     }
   };
 
   return (
     <div>
       <h2>ユーザ管理</h2>
       <button onClick={handleCreateUser}>新規ユーザ作成</button>

       {loading ? (
         <div>読み込み中...</div>
       ) : (
         <ul>
           {users.map((user) => (
             <li key={user.id}>
               {user.name} ({user.email})
             </li>
           ))}
         </ul>
       )}
     </div>
   );
 }


型エクスポートの自動化

ビルドプロセスに型エクスポートを統合する。

 // src-tauri/build.rs (ビルドスクリプト)
 fn main() {
    // ビルド前に型をエクスポート
    #[cfg(debug_assertions)]
    {
       println!("cargo:rerun-if-changed=src/lib.rs");
       // build.rsからは直接エクスポートできないため、別の方法 (テストやCI) で型をエクスポートする
    }
 }


または、テストとして定義する。

 // src-tauri/src/lib.rs
 #[cfg(test)]
 mod tests {
    use super::*;
    use tauri_specta::ts;
    use specta::collect_types;
 
    #[test]
    fn export_bindings() {
       // テスト実行時にTypeScript型をエクスポート
       ts::export(
          collect_types![get_user, create_user, list_users],
          "../src/bindings.ts"
       ).unwrap();
    }
 }


# テストを実行して型をエクスポート
cargo test export_bindings



サンプルコード

状態管理ライブラリとの連携

Zustand等の状態管理ライブラリとTauriを連携させる例を以下に示す。

 // src/store/appStore.ts
 import { create } from 'zustand';
 import { invoke } from '@tauri-apps/api/core';
 
 interface AppState {
   // 状態
   user: User | null;
   settings: Settings | null;
   loading: boolean;
   error: string | null;
 
   // アクション
   fetchUser: (id: number) => Promise<void>;
   updateSettings: (settings: Partial<Settings>) => Promise<void>;
   reset: () => void;
 }
 
 interface User {
   id: number;
   name: string;
   email: string;
 }
 
 interface Settings {
   theme: string;
   language: string;
   notifications: boolean;
 }
 
 export const useAppStore = create<AppState>((set, get) => ({
   // 初期状態
   user: null,
   settings: null,
   loading: false,
   error: null,
 
   // ユーザ取得
   fetchUser: async (id: number) => {
     set({ loading: true, error: null });
 
     try {
       const user = await invoke<User>('get_user', { id });
       set({ user, loading: false });
     }
     catch (error) {
       const message = typeof error === 'string' ? error : 'Unknown error';
       set({ error: message, loading: false });
     }
   },
 
   // 設定更新
   updateSettings: async (newSettings: Partial<Settings>) => {
     const currentSettings = get().settings;
     if (!currentSettings) return;
 
     const mergedSettings = { ...currentSettings, ...newSettings };
 
     try {
       await invoke('update_settings', { settings: mergedSettings });
       set({ settings: mergedSettings });
     }
     catch (error) {
       const message = typeof error === 'string' ? error : 'Unknown error';
       set({ error: message });
     }
   },
 
   // リセット
   reset: () => {
     set({ user: null, settings: null, loading: false, error: null });
   },
 }));


フォーム処理

React Hook FormとTauriを組み合わせたフォーム処理の例を以下に示す。

 // src/components/UserForm.tsx
 import { useForm } from 'react-hook-form';
 import { invoke } from '@tauri-apps/api/core';
 
 interface UserFormData {
   name: string;
   email: string;
   age: number;
   bio: string;
 }
 
 interface UserFormProps {
   onSuccess?: (user: UserFormData) => void;
 }
 
 export function UserForm({ onSuccess }: UserFormProps) {
   const {
     register,
     handleSubmit,
     formState: { errors, isSubmitting },
     setError,
   } = useForm<UserFormData>();
 
   // フォーム送信処理
   const onSubmit = async (data: UserFormData) => {
     try {
       // Rustコマンドを呼び出し
       const result = await invoke<{ success: boolean; id: number }>(
         'create_user',
         { user: data }
       );
 
       if (result.success) {
         onSuccess?.(data);
       }
     }
     catch (error) {
       // サーバー側バリデーションエラー
       const message = typeof error === 'string' ? error : 'Unknown error';
       setError('root', { message });
     }
   };
 
   return (
     <form onSubmit={handleSubmit(onSubmit)}>
       {/* 名前フィールド */}
       <div>
         <label>名前</label>
         <input
           {...register('name', {
             required: '名前は必須です',
             minLength: { value: 2, message: '2文字以上で入力してください' },
           })}
         />
         {errors.name && <span className="error">{errors.name.message}</span>}
       </div>
 
       {/* メールフィールド */}
       <div>
         <label>メール</label>
         <input
           type="email"
           {...register('email', {
             required: 'メールは必須です',
             pattern: {
               value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
               message: '有効なメールアドレスを入力してください',
             },
           })}
         />
         {errors.email && <span className="error">{errors.email.message}</span>}
       </div>
 
       {/* 年齢フィールド */}
       <div>
         <label>年齢</label>
         <input
           type="number"
           {...register('age', {
             required: '年齢は必須です',
             min: { value: 0, message: '0以上の値を入力してください' },
             max: { value: 150, message: '有効な年齢を入力してください' },
           })}
         />
         {errors.age && <span className="error">{errors.age.message}</span>}
       </div>
 
       {/* 送信ボタン */}
       <button type="submit" disabled={isSubmitting}>
         {isSubmitting ? '送信中...' : '登録'}
       </button>
 
       {/* 全体エラー */}
       {errors.root && <div className="error">{errors.root.message}</div>}
     </form>
   );
 }


ファイル操作

ファイル選択とTauriバックエンドでのファイル処理の例を以下に示す。

 // src/components/FileProcessor.tsx
 import { useState } from 'react';
 import { open } from '@tauri-apps/plugin-dialog';
 import { invoke } from '@tauri-apps/api/core';
 
 interface ProcessResult {
   success: boolean;
   message: string;
   data?: unknown;
 }
 
 export function FileProcessor() {
   const [selectedPath, setSelectedPath] = useState<string | null>(null);
   const [processing, setProcessing] = useState(false);
   const [result, setResult] = useState<ProcessResult | null>(null);
 
   // ファイル選択ダイアログを開く
   const handleSelectFile = async () => {
     const selected = await open({
       multiple: false,
       filters: [
         { name: 'Text Files', extensions: ['txt', 'md', 'json'] },
         { name: 'All Files', extensions: ['*'] },
       ],
     });
 
     if (selected) {
       setSelectedPath(selected as string);
       setResult(null);
     }
   };
 
   // ファイル処理を実行
   const handleProcess = async () => {
     if (!selectedPath) return;
 
     setProcessing(true);
     setResult(null);
 
     try {
       const processResult = await invoke<ProcessResult>('process_file', {
         path: selectedPath,
       });
       setResult(processResult);
     }
     catch (error) {
       const message = typeof error === 'string' ? error : 'Unknown error';
       setResult({ success: false, message });
     }
     finally {
       setProcessing(false);
     }
   };
 
   return (
     <div className="file-processor">
       <h2>ファイル処理</h2>
 
       {/* ファイル選択 */}
       <button onClick={handleSelectFile}>
         ファイルを選択
       </button>
 
       {/* 選択されたファイル */}
       {selectedPath && (
         <div className="selected-file">
           選択: {selectedPath}
         </div>
       )}
 
       {/* 処理実行 */}
       <button
         onClick={handleProcess}
         disabled={!selectedPath || processing}
       >
         {processing ? '処理中...' : '処理実行'}
       </button>
 
       {/* 結果表示 */}
       {result && (
         <div className={`result ${result.success ? 'success' : 'error'}`}>
           {result.message}
           {result.data && (
             <pre>{JSON.stringify(result.data, null, 2)}</pre>
           )}
         </div>
       )}
     </div>
   );
 }



参考リンク