概要
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アプリケーションにおける状態のライフサイクルは以下の通りである。
- アプリケーション起動時に、
Builder::manage()で状態を登録する。 - コマンド呼び出し時に
State<T>パラメータで自動注入する。 - 状態はアプリケーション終了まで保持される。
- 複数のコマンド間で同じ状態インスタンスを共有する。
なぜ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の種類の使い分け
| 種類 | 使用場面 | ロック取得方法 | 注意点 |
|---|---|---|---|
| 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::{AppHandle, Manager};
use std::sync::Mutex;
use std::time::Duration;
fn start_background_task(app: AppHandle) {
// 非同期タスクを開始
// moveキーワードでappの所有権をタスクに移動
tokio::spawn(async move {
// 無限ループで定期的な処理を実行
loop {
// 60秒待機
tokio::time::sleep(Duration::from_secs(60)).await;
// バックグラウンドから状態にアクセス
// AppHandleを通じて登録済みの状態を取得
let state = app.state::<Mutex<AppState>>();
// Mutexをロックして可変参照を取得
let mut state = state.lock().unwrap();
// 最終同期時刻を更新
state.last_sync = Some(chrono::Utc::now());
// 定期的な同期処理等
println!("Background sync completed");
}
});
}
バックグラウンドタスクでの使用
バックグラウンドスレッドやタイマ処理から状態にアクセスする場合も、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/core の invoke() 関数を使用して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); // エラーメッセージ (エラーがない場合はnull)
// コンポーネントマウント時に初期値を取得
useEffect(() => {
fetchCounter();
}, []);
// Rustバックエンドからカウンター値を取得する関数
const fetchCounter = useCallback(async () => {
setLoading(true); // ロード開始
setError(null); // エラーをクリア
try {
// Rustコマンド "get_counter" を呼び出し
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 {
// Rustコマンド "increment_counter" を呼び出し
// 戻り値として新しいカウンター値を取得
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; // 現在のユーザ (未ログインならnull)
settings: Settings; // アプリケーション設定
notifications: Notification[]; // 通知リスト
}
// ユーザ情報の型
interface User {
id: string;
name: string;
email: string;
}
// アプリケーション設定の型
interface Settings {
theme: 'light' | 'dark'; // テーマ (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>; // 状態再取得関数
}
// Reactコンテキストを作成 (初期値はnull)
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);
// Rustバックエンドから状態を取得する関数
const refreshState = useCallback(async () => {
setLoading(true); // ロード開始
setError(null); // エラーをクリア
try {
// Rustコマンドを呼び出して状態を取得
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 {
// Rustコマンドでユーザを更新
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 {
// Rustコマンドで設定を更新し、新しい設定を取得
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 {
// Rustコマンドで通知を作成
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, // テーマ ("light" または "dark")
language: String, // 言語設定
notifications: bool, // 通知の有効/無効
}
// 通知メッセージを表す構造体
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Notification {
id: String, // 通知の一意識別子
message: String, // 通知メッセージ本文
timestamp: String, // タイムスタンプ (ISO 8601形式)
}
// アプリケーション全体の状態を管理する構造体
#[derive(Debug, Default)]
struct AppState {
user: Option<User>, // 現在のユーザー (未ログインならNone)
settings: Settings, // アプリケーション設定
notifications: Vec<Notification>, // 通知リスト
}
// Settings構造体のデフォルト値を定義
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(); // Mutexをロック
// 状態をJSONとして返す
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(); // Mutexをロック (可変参照)
state.user = Some(user); // ユーザ情報を設定
}
// 設定を部分的に更新するコマンド
#[tauri::command]
fn update_settings(
state: State<'_, Mutex<AppState>>,
settings: PartialSettings, // 部分的な設定データ
) -> Settings {
let mut state = state.lock().unwrap();
// 各フィールドがSomeの場合のみ更新
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() // 更新後の設定を返す
}
// 部分的な設定データ (全フィールドがOptional)
#[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(), // UUIDを生成
message,
timestamp: chrono::Utc::now().to_rfc3339(), // 現在時刻をISO形式で設定
};
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(()) }
関連情報
- Tauri公式ドキュメント - State Management
- tauri::State API リファレンス
- std::sync::Mutex ドキュメント
- Tauriの基礎 - ウインドウ管理
- Tauriの基礎 - メニューとシステムトレイ
- Tauriの基礎 - セキュリティモデル