Tauriの基礎 - 状態管理

提供: MochiuWiki : SUSE, EC, PCB

2026年3月4日 (水) 18:54時点におけるWiki (トーク | 投稿記録)による版 (ページの作成:「== 概要 == Tauriの状態管理は、Rustバックエンドとフロントエンド (WebView) 間でアプリケーション全体の状態を共有・管理するための仕組みである。<br> <br> 従来のWebアプリケーションでは、フロントエンド側で状態を管理するのが一般的だが、TauriアプリケーションではRustバックエンド側で状態を保持し、必要に応じてフロントエンドから参照・更新で…」)
(差分) ← 古い版 | 最新版 (差分) | 新しい版 → (差分)

概要

Tauriの状態管理は、Rustバックエンドとフロントエンド (WebView) 間でアプリケーション全体の状態を共有・管理するための仕組みである。

従来のWebアプリケーションでは、フロントエンド側で状態を管理するのが一般的だが、TauriアプリケーションではRustバックエンド側で状態を保持し、必要に応じてフロントエンドから参照・更新できる。
これにより、ファイルシステムへのアクセス、データベース操作、ネットワーク通信等、OSレベルのリソースを必要とする状態を安全に管理できる。

Tauriの状態管理は、manage() メソッドによる状態の登録、tauri::State<T> によるコマンド内でのアクセス、Mutex<T> による可変状態の実現を組み合わせて実装する。

また、非同期処理が必要な場合は、tokio::sync::Mutex を使用することにより、awaitポイントを跨いだロック保持が可能となる。

AppHandleを使用することで、アプリケーションの任意の場所から状態にアクセスすることもでき、柔軟なアーキテクチャ設計をサポートする。

フロントエンド (React + TypeScript) からは、invoke()関数を通じてRustコマンドを呼び出して、状態の取得・更新を行う。

状態管理の基本概念

アーキテクチャ概要

Tauriアプリケーションは、RustバックエンドとWebViewフロントエンドの2つの主要なコンポーネントで構成される。

状態管理の点から見ると、以下に示す特徴がある。

  • Rustバックエンド
    アプリケーションのコアロジック、OS APIへのアクセス、永続化処理を担当
    状態はここで保持され、スレッドセーフに管理される。

  • WebViewフロントエンド
    ユーザインターフェース (React等) を担当
    Rustコマンドを通じて間接的に状態にアクセス

  • IPCレイヤー
    RustとWebView間の通信を仲介
    JSONベースのメッセージパッシング


状態のライフサイクル

Tauriアプリケーションにおける状態のライフサイクルは以下の通りである。

  1. アプリケーション起動時に、Builder::manage() で状態を登録する。
  2. コマンド呼び出し時にState<T>パラメータで自動注入する。
  3. 状態はアプリケーション終了まで保持される。
  4. 複数のコマンド間で同じ状態インスタンスを共有する。


なぜRust側で状態を管理するのか

  • セキュリティ
    フロントエンドは潜在的に信頼できないコードを実行する可能性がある。
    機密データはRust側で保護された状態で管理
  • パフォーマンス
    重い計算処理はRust側で効率的に実行
    ネイティブAPIへの直接アクセス
  • 永続化
    ファイルシステムやデータベースへのアクセスはRust側で一元管理



manage()によるState登録

基本的な登録方法

Builder::manage() メソッドを使用して、アプリケーション起動時に状態を登録する。

 // src-tauri/src/lib.rs
 use std::sync::Mutex;
 use tauri::Builder;
 
 // アプリケーション状態を定義
 #[derive(Default)]
 struct AppState {
    counter: u32,
    user_name: Option<String>,
    settings: AppSettings,
 }
 
 #[derive(Default)]
 struct AppSettings {
    theme: String,
    language: String,
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       // 状態を登録 (Mutexでラップして可変に)
       .manage(Mutex::new(AppState::default()))
       .invoke_handler(tauri::generate_handler![
          get_counter,
          increment_counter,
          set_user_name,
       ])
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


複数の状態の登録

異なるタイプの状態を複数登録することも可能である。

 use std::sync::Mutex;
 use tauri::Builder;
 
 // データベース接続プール
 struct DatabasePool {
    connections: Vec<Connection>,
 }
 
 // キャッシュ
 struct Cache {
    data: HashMap<String, String>,
 }
 
 // アプリケーション設定
 struct AppConfig {
    debug_mode: bool,
    api_endpoint: String,
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .manage(Mutex::new(DatabasePool::new()))
       .manage(Mutex::new(Cache::new()))
       .manage(AppConfig {
          debug_mode: false,
          api_endpoint: "https://api.example.com".to_string(),
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


状態の初期化

状態は必ずしも Default トレイトを実装する必要はない。
カスタム初期化も可能である。

 use std::sync::Mutex;
 use tauri::Builder;
 
 struct AppState {
    counter: u32,
    initialized_at: std::time::Instant,
 }
 
 impl AppState {
    fn new() -> Self {
       Self {
          counter: 0,
          initialized_at: std::time::Instant::now(),
       }
    }
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       // カスタム初期化
       .manage(Mutex::new(AppState::new()))
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }



tauri::State<T>によるコマンド内アクセス

読み取り専用アクセス

State<T> パラメータをコマンド関数に追加することにより、状態への参照を取得できる。

 use tauri::State;
 use std::sync::Mutex;
 
 #[tauri::command]
 fn get_counter(state: State<'_, Mutex<AppState>>) -> u32 {
    // ロックを取得して値を読み取る
    let state = state.lock().unwrap();
    state.counter
 }
 
 #[tauri::command]
 fn get_user_name(state: State<'_, Mutex<AppState>>) -> Option<String> {
    let state = state.lock().unwrap();
    state.user_name.clone()
 }


自動注入の仕組み

Tauriは、コマンド関数のパラメータに State<T> が含まれている場合、自動的に登録済みの状態を注入する。

  • 依存性注入 (Dependency Injection) パターン
  • 型推論により適切な状態が選択される。
  • 複数の State<T> パラメータも可能


 use tauri::State;
 use std::sync::Mutex;
 
 #[tauri::command]
 fn get_combined_info(
    app_state: State<'_, Mutex<AppState>>,
    config: State<'_, AppConfig>,
 ) -> String {
    let state = app_state.lock().unwrap();
    format!(
       "User: {:?}, Counter: {}, Debug: {}",
       state.user_name, state.counter, config.debug_mode
    )
 }


Arcが不要な理由

Tauriの State<T> は、内部的に Arc<T> でラップされているため、開発者が明示的に Arc を使用する必要はない。

 // 正しい例
 .manage(Mutex::new(AppState::default()))
 
 // 不要な例
 let state = Arc::new(Mutex::new(AppState::default()));
 .manage(state)



Mutex<T>による可変State

std::sync::Mutex (同期版)

同期コマンド (非async関数) で使用する。
ロック取得は、.lock().unwrap() で行う。

 use std::sync::Mutex;
 use tauri::State;
 
 #[tauri::command]
 fn increment_counter(state: State<'_, Mutex<AppState>>) -> u32 {
    // ミュータブルなアクセスを取得
    let mut state = state.lock().unwrap();
    state.counter += 1;
    state.counter
 }
 
 #[tauri::command]
 fn set_user_name(state: State<'_, Mutex<AppState>>, name: String) {
    let mut state = state.lock().unwrap();
    state.user_name = Some(name);
 }


tokio::sync::Mutex (非同期版)

非同期コマンド (async関数) で、awaitポイントを跨いでロックを保持する場合に使用する。

 use tokio::sync::Mutex;
 use tauri::State;
 
 #[tauri::command]
 async fn increment_counter_async(state: State<'_, Mutex<AppState>>) -> Result<u32, String> {
    // 非同期ロックを取得
    let mut state = state.lock().await;
    state.counter += 1;
    Ok(state.counter)
 }
 
 #[tauri::command]
 async fn fetch_and_cache_data(
    state: State<'_, Mutex<AppState>>,
    url: String,
 ) -> Result<String, String> {
    let mut state = state.lock().await;
 
    // 非同期操作 (awaitポイント)
    let response = reqwest::get(&url).await
       .map_err(|e| e.to_string())?;
    let body = response.text().await
       .map_err(|e| e.to_string())?;
 
    // ロックを保持したまま状態を更新
    state.cached_data = Some(body.clone());
    Ok(body)
 }


Mutexの種類の使い分け

Mutexの選択基準
種類 使用場面 ロック取得方法 注意点
std::sync::Mutex 同期コマンド .lock().unwrap() awaitポイントを跨げない
tokio::sync::Mutex 非同期コマンド .lock().await 非同期コンテキストが必要
std::sync::RwLock 読み取りが多い場合 .read()/.write() 書き込みは排他的



ロックエラーのハンドリング

.lock().unwrap() は、ロック取得に失敗した場合にパニックを起こす。
より安全なエラーハンドリングには、.lock().expect()match を使用する。

 use std::sync::Mutex;
 use tauri::State;
 
 #[tauri::command]
 fn safe_increment(state: State<'_, Mutex<AppState>>) -> Result<u32, String> {
    // 安全なロック取得
    let mut state = state.lock()
       .map_err(|e| format!("Failed to acquire lock: {}", e))?;
 
    state.counter += 1;
    Ok(state.counter)
 }



AppHandleからのState取得

Managerトレイトの使用

AppHandle は、Managerトレイトを実装しており、.state::<T>() メソッドで状態にアクセスできる。

 use tauri::{AppHandle, Manager};
 use std::sync::Mutex;
 
 #[tauri::command]
 fn get_state_via_handle(app: AppHandle) -> u32 {
    // AppHandleから状態を取得
    let state = app.state::<Mutex<AppState>>();
    let state = state.lock().unwrap();
    state.counter
 }


イベントハンドラでの使用

イベントハンドラ内では、AppHandle を使用して状態にアクセスする。

 use tauri::{Builder, Manager, AppHandle};
 use std::sync::Mutex;
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .manage(Mutex::new(AppState::default()))
       .setup(|app| {
          // セットアップ内で状態にアクセス
          let state = app.state::<Mutex<AppState>>();
          let mut state = state.lock().unwrap();
          state.initialized_at = Some(std::time::Instant::now());
          Ok(())
       })
       .on_window_event(|window, event| {
          if let tauri::WindowEvent::CloseRequested { .. } = event {
             // ウインドウクローズ時に状態を保存
             let app = window.app_handle();
             let state = app.state::<Mutex<AppState>>();
             let state = state.lock().unwrap();
             println!("Final counter value: {}", state.counter);
          }
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


バックグラウンドタスクでの使用

バックグラウンドスレッドやタイマ処理から状態にアクセスする場合も、AppHandle を使用する。

 use tauri::{AppHandle, Manager};
 use std::sync::Mutex;
 use std::time::Duration;
 
 fn start_background_task(app: AppHandle) {
    tokio::spawn(async move {
       loop {
          tokio::time::sleep(Duration::from_secs(60)).await;
 
          // バックグラウンドから状態にアクセス
          let state = app.state::<Mutex<AppState>>();
          let mut state = state.lock().unwrap();
          state.last_sync = Some(chrono::Utc::now());
 
          // 定期的な同期処理等
          println!("Background sync completed");
       }
    });
 }



フロントエンドからのCommand経由参照

invoke()の基本

フロントエンド (React + TypeScript) からは、@tauri-apps/api/coreinvoke() 関数を使用してRustコマンドを呼び出す。

 // src/hooks/useAppState.ts
 import { invoke } from '@tauri-apps/api/core';
 
 // 状態の型定義
 interface AppState {
   counter: number;
   userName: string | null;
 }
 
 // カウンタを取得
 export async function getCounter(): Promise<number> {
   return await invoke<number>('get_counter');
 }
 
 // カウンタをインクリメント
 export async function incrementCounter(): Promise<number> {
   return await invoke<number>('increment_counter');
 }
 
 // ユーザ名を設定
 export async function setUserName(name: string): Promise<void> {
   await invoke('set_user_name', { name });
 }


Reactカスタムフック

状態管理用のカスタムフックを作成することにより、コンポーネントでの使用を簡素化できる。

 // src/hooks/useCounter.ts
 import { useState, useEffect, useCallback } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 export function useCounter() {
   const [count, setCount] = useState<number>(0);
   const [loading, setLoading] = useState<boolean>(false);
   const [error, setError] = useState<string | null>(null);
 
   // 初期値を取得
   useEffect(() => {
     fetchCounter();
   }, []);
 
   const fetchCounter = useCallback(async () => {
     setLoading(true);
     setError(null);
     try {
       const value = await invoke<number>('get_counter');
       setCount(value);
     }
     catch (err) {
       setError(String(err));
     }
     finally {
       setLoading(false);
     }
   }, []);
 
   const increment = useCallback(async () => {
     setLoading(true);
     setError(null);
     try {
       const newValue = await invoke<number>('increment_counter');
       setCount(newValue);
       return newValue;
     }
     catch (err) {
       setError(String(err));
       throw err;
     }
     finally {
       setLoading(false);
     }
   }, []);
 
   return {
     count,
     loading,
     error,
     fetchCounter,
     increment,
   };
 }


Reactコンポーネントでの使用

 // src/components/Counter.tsx
 import React from 'react';
 import { useCounter } from '../hooks/useCounter';
 
 export function Counter() {
   const { count, loading, error, increment } = useCounter();
 
   return (
     <div className="counter-container">
       <h2>カウンタ</h2>
 
       {error && (
         <div className="error">
           エラー: {error}
         </div>
       )}
 
       <p>現在の値: {count}</p>
 
       <button
         onClick={increment}
         disabled={loading}
       >
         {loading ? '処理中...' : 'インクリメント'}
       </button>
     </div>
   );
 }



React + TypeScriptでの実装例

完全な状態管理パターン

より複雑なアプリケーション向けに、Context APIと組み合わせた状態管理パターンを示す。

 // src/context/AppStateContext.tsx
 import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
 import { invoke } from '@tauri-apps/api/core';
 
 // 型定義
 interface AppState {
   user: User | null;
   settings: Settings;
   notifications: Notification[];
 }
 
 interface User {
   id: string;
   name: string;
   email: string;
 }
 
 interface Settings {
   theme: 'light' | 'dark';
   language: string;
   notifications: boolean;
 }
 
 interface Notification {
   id: string;
   message: string;
   timestamp: Date;
 }
 
 // コンテキストの型
 interface AppStateContextType {
   state: AppState;
   loading: boolean;
   error: string | null;
   updateUser: (user: User) => Promise<void>;
   updateSettings: (settings: Partial<Settings>) => Promise<void>;
   addNotification: (message: string) => Promise<void>;
   refreshState: () => Promise<void>;
 }
 
 // コンテキストの作成
 const AppStateContext = createContext<AppStateContextType | null>(null);
 
 // プロバイダーコンポーネント
 export function AppStateProvider({ children }: { children: ReactNode }) {
   const [state, setState] = useState<AppState>({
     user: null,
     settings: { theme: 'light', language: 'ja', notifications: true },
     notifications: [],
   });
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
 
   // 状態を取得
   const refreshState = useCallback(async () => {
     setLoading(true);
     setError(null);
     try {
       const appState = await invoke<AppState>('get_full_state');
       setState(appState);
     }
     catch (err) {
       setError(String(err));
     }
     finally {
       setLoading(false);
     }
   }, []);
 
   // 初期化時に状態を取得
   useEffect(() => {
     refreshState();
   }, [refreshState]);
 
   // ユーザを更新
   const updateUser = useCallback(async (user: User) => {
     setLoading(true);
     setError(null);
     try {
       await invoke('update_user', { user });
       setState(prev => ({ ...prev, user }));
     }
     catch (err) {
       setError(String(err));
       throw err;
     }
     finally {
       setLoading(false);
     }
   }, []);
 
   // 設定を更新
   const updateSettings = useCallback(async (settings: Partial<Settings>) => {
     setLoading(true);
     setError(null);
     try {
       const newSettings = await invoke<Settings>('update_settings', { settings });
       setState(prev => ({ ...prev, settings: newSettings }));
     }
     catch (err) {
       setError(String(err));
       throw err;
     }
     finally {
       setLoading(false);
     }
   }, []);
 
   // 通知を追加
   const addNotification = useCallback(async (message: string) => {
     try {
       const notification = await invoke<Notification>('add_notification', { message });
       setState(prev => ({
         ...prev,
         notifications: [...prev.notifications, notification],
       }));
     }
     catch (err) {
       console.error('Failed to add notification:', err);
     }
   }, []);
 
   return (
     <AppStateContext.Provider
       value={{
         state,
         loading,
         error,
         updateUser,
         updateSettings,
         addNotification,
         refreshState,
       }}
     >
       {children}
     </AppStateContext.Provider>
   );
 }
 
 // カスタムフック
 export function useAppState() {
   const context = useContext(AppStateContext);
   if (!context) {
     throw new Error('useAppState must be used within AppStateProvider');
   }
   return context;
 }


対応するRust実装

 // src-tauri/src/lib.rs
 use std::sync::Mutex;
 use tauri::{Builder, State};
 use serde::{Deserialize, Serialize};
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 struct User {
    id: String,
    name: String,
    email: String,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 struct Settings {
    theme: String,
    language: String,
    notifications: bool,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 struct Notification {
    id: String,
    message: String,
    timestamp: String,
 }
 
 #[derive(Debug, Default)]
 struct AppState {
    user: Option<User>,
    settings: Settings,
    notifications: Vec<Notification>,
 }
 
 impl Default for Settings {
    fn default() -> Self {
       Self {
          theme: "light".to_string(),
          language: "ja".to_string(),
          notifications: true,
       }
    }
 }
 
 #[tauri::command]
 fn get_full_state(state: State<'_, Mutex<AppState>>) -> serde_json::Value {
    let state = state.lock().unwrap();
    serde_json::json!({
       "user": state.user,
       "settings": state.settings,
       "notifications": state.notifications,
    })
 }
 
 #[tauri::command]
 fn update_user(state: State<'_, Mutex<AppState>>, user: User) {
    let mut state = state.lock().unwrap();
    state.user = Some(user);
 }
 
 #[tauri::command]
 fn update_settings(
    state: State<'_, Mutex<AppState>>,
    settings: PartialSettings,
 ) -> Settings {
    let mut state = state.lock().unwrap();
    if let Some(theme) = settings.theme {
       state.settings.theme = theme;
    }
    if let Some(language) = settings.language {
       state.settings.language = language;
    }
    if let Some(notifications) = settings.notifications {
       state.settings.notifications = notifications;
    }
    state.settings.clone()
 }
 
 #[derive(Deserialize)]
 struct PartialSettings {
    theme: Option<String>,
    language: Option<String>,
    notifications: Option<bool>,
 }
 
 #[tauri::command]
 fn add_notification(
    state: State<'_, Mutex<AppState>>,
    message: String,
 ) -> Notification {
    let mut state = state.lock().unwrap();
    let notification = Notification {
       id: uuid::Uuid::new_v4().to_string(),
       message,
       timestamp: chrono::Utc::now().to_rfc3339(),
    };
    state.notifications.push(notification.clone());
    notification
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .manage(Mutex::new(AppState::default()))
       .invoke_handler(tauri::generate_handler![
          get_full_state,
          update_user,
          update_settings,
          add_notification,
       ])
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }



推奨される事柄

スレッドセーフティ

  • Mutexを使用する。
    可変状態は、必ず Mutex または RwLock で保護する。
  • ロック時間を最小化
    ロック保持中は他のスレッドがブロックされるため、処理を最小限にする。


 // 良い例 : ロック時間を最小化
 #[tauri::command]
 fn good_example(state: State<'_, Mutex<AppState>>) -> String {
    // ロック内で必要なデータのみをコピー
    let value = {
       let state = state.lock().unwrap();
       state.counter.to_string()
    };
 
    // ロック解放後の処理
    format!("Counter: {}", value)
 }
 
 // 悪い例 : ロック時間が長い
 #[tauri::command]
 fn bad_example(state: State<'_, Mutex<AppState>>) -> String {
    let state = state.lock().unwrap();
    // 重い処理をロック中に実行
    let result = expensive_computation(&state.data);
    result
 }


状態の分割

巨大な状態オブジェクトを分割し、関心ごとに分けて管理する。

 // 良い例 : 状態を分割
 struct UserState {
    current_user: Option<User>,
 }
 
 struct CacheState {
    data: HashMap<String, String>,
 }
 
 struct SettingsState {
    theme: String,
    language: String,
 }
 
 // それぞれ個別に登録
 Builder::default()
    .manage(Mutex::new(UserState::default()))
    .manage(Mutex::new(CacheState::default()))
    .manage(SettingsState::default())


エラーハンドリング

状態操作時のエラーを適切に処理し、ユーザにフィードバックを提供する。

 use thiserror::Error;
 
 #[derive(Error, Debug)]
 pub enum StateError {
    #[error("Lock acquisition failed: {0}")]
    LockError(String),
    #[error("Invalid state: {0}")]
    InvalidState(String),
 }
 
 #[tauri::command]
 fn safe_operation(state: State<'_, Mutex<AppState>>) -> Result<String, StateError> {
    let mut state = state.lock()
       .map_err(|e| StateError::LockError(e.to_string()))?;
 
    if state.counter < 0 {
       return Err(StateError::InvalidState("Counter cannot be negative".to_string()));
    }
 
    Ok(state.counter.to_string())
 }



トラブルシューティング

エラー : state not found

  • 原因
    状態が manage() で登録されていない。
  • 解決方法
     // 状態を登録する
     Builder::default()
        .manage(Mutex::new(AppState::default()))
        .invoke_handler(tauri::generate_handler![your_command])
    


エラー : PoisonError

  • 原因
    別のスレッドがロック保持中にパニックを起こした。
  • 解決方法
     // ロック回復を試みる
     let state = state.lock().unwrap_or_else(|e| {
        eprintln!("Lock poisoned: {}", e);
        e.into_inner()  // 毒状態のデータを回復
     });
    


非同期コマンドでのデッドロック
  • 原因
    std::sync::Mutex をawaitポイントを跨いで保持している。
  • 解決方法
     // 非同期コマンドでは tokio::sync::Mutex を使用
     use tokio::sync::Mutex;
     
     #[tauri::command]
     async fn async_operation(state: State<'_, Mutex<AppState>>) -> Result<(), String> {
        let mut state = state.lock().await;
     
        // awaitポイントを跨いでも安全
        let data = fetch_data().await?;
        state.data = data;
        Ok(())
     }
    



関連情報