T-CREATOR

Pinia トランザクション更新設計:一括 set・部分適用・ロールバックの整合モデル

Pinia トランザクション更新設計:一括 set・部分適用・ロールバックの整合モデル

Vue 3 アプリケーションで複雑なフォームや多段階の処理を実装していると、「複数の状態を同時に更新したい」「途中でエラーが起きたら元に戻したい」といった要求に直面することがあります。

Pinia は Vue 3 の公式状態管理ライブラリとして、シンプルで使いやすい API を提供していますが、トランザクション的な更新機能は標準では備わっていません。そこで本記事では、Pinia で一括 set、部分適用、ロールバックを実現するトランザクション更新設計について、実装パターンと整合性を保つためのポイントを詳しく解説します。

実際のコード例を交えながら、データの整合性を保ちつつ柔軟な状態管理を実現する方法をご紹介しますので、ぜひ最後までお読みください。

背景

Pinia の基本的な状態更新

Pinia では、store の状態を更新する方法として主に 3 つのアプローチが用意されています。

直接プロパティを変更する方法、$patch メソッドを使う方法、そして actions を定義する方法です。それぞれに特徴がありますが、複雑な更新処理では課題も見えてきます。

直接変更パターン

typescriptimport { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    email: '',
    age: 0,
    address: '',
  }),
});
typescript// コンポーネント内での使用例
const userStore = useUserStore();

// 直接プロパティを変更
userStore.name = '田中太郎';
userStore.email = 'tanaka@example.com';

このパターンは最もシンプルですが、複数のプロパティを更新する際に途中でエラーが発生すると、一部だけが更新された中途半端な状態になってしまいます。

$patch メソッドによる更新

typescript// オブジェクト形式での一括更新
userStore.$patch({
  name: '田中太郎',
  email: 'tanaka@example.com',
  age: 30,
});

$patch は内部的に一度の処理として扱われるため、リアクティブシステムのパフォーマンスが向上します。しかし、エラー時のロールバックや条件付き更新には対応していません。

状態更新の基本フロー

以下の図は、Pinia における通常の状態更新フローを示しています。

mermaidflowchart TD
  Start["開始"] --> DirectChange["直接変更"]
  Start --> PatchMethod["$patch メソッド"]
  Start --> Action["Actions"]

  DirectChange --> Update1["state.name 更新"]
  DirectChange --> Update2["state.email 更新"]
  DirectChange --> Update3["state.age 更新"]

  PatchMethod --> BatchUpdate["一括更新"]

  Action --> Logic["ビジネスロジック"]
  Logic --> StateUpdate["状態更新"]

  Update1 --> Done["完了"]
  Update2 --> Done
  Update3 --> Done
  BatchUpdate --> Done
  StateUpdate --> Done

図で理解できる要点:

  • 直接変更は個別のプロパティを順次更新する
  • $patch は複数のプロパティを一括で更新できる
  • Actions 内でビジネスロジックと状態更新を組み合わせられる

実務でのニーズ

実際の開発現場では、ユーザー登録フォームや商品注文処理など、複数の状態を一貫性を持って更新する必要があるケースが頻繁にあります。

例えば、API 呼び出しの結果を store に反映する際、通信エラーや検証エラーが発生したら、状態を更新前の状態に戻したいという要求は自然なものです。データベースのトランザクションと同じように、「全て成功するか、全て失敗するか」という原子性を確保したいわけですね。

課題

一括更新時の整合性問題

複数の関連する状態を更新する際、途中で処理が失敗すると、一部だけが更新された不整合な状態が発生します。

typescript// 問題のあるコード例
const updateUserProfile = async () => {
  try {
    // ステップ1:名前を更新
    userStore.name = newName;

    // ステップ2:メールアドレスを検証して更新
    await validateEmail(newEmail); // ここでエラーが発生する可能性
    userStore.email = newEmail;

    // ステップ3:住所を更新
    userStore.address = newAddress;
  } catch (error) {
    // エラー時、name だけが更新された状態になる
    console.error('更新失敗:', error);
  }
};

このコードでは、validateEmail でエラーが発生すると、name だけが新しい値になり、emailaddress は古い値のままになってしまいます。

部分適用の制御の難しさ

状況によっては、「エラーが出ても一部の更新は適用したい」というケースもあります。

例えば、オプション項目の更新は失敗しても構わないが、必須項目の更新は必ず成功させたい、といった要求です。しかし、どの項目を適用してどの項目をスキップするかを柔軟に制御する仕組みが必要になります。

typescript// 部分適用が必要なケース
const updateProfile = async (
  updates: Partial<UserState>
) => {
  // 必須項目
  if (updates.name) {
    await validateName(updates.name);
    userStore.name = updates.name;
  }

  // オプション項目(エラーでも続行)
  if (updates.address) {
    try {
      userStore.address = updates.address;
    } catch (error) {
      console.warn('住所の更新は失敗しましたが続行します');
    }
  }
};

このような条件分岐が増えると、コードの可読性が低下し、メンテナンスが困難になります。

ロールバック機能の欠如

標準の Pinia には、更新前の状態を自動的に保存してロールバックする機能がありません。

エラー発生時に元の状態に戻すには、開発者が手動でスナップショットを取得し、復元処理を実装する必要があります。これはボイラープレートコードを増やし、バグの温床になりかねません。

課題の構造図

以下の図は、トランザクション管理がない場合の問題点を示しています。

mermaidflowchart TD
  Start["更新開始"] --> Step1["Step1: name 更新"]
  Step1 --> Success1["成功"]
  Success1 --> Step2["Step2: email 検証"]
  Step2 --> Error["エラー発生"]
  Error --> Inconsistent["不整合な状態<br/>nameだけ更新済み"]

  Step2 --> Success2["成功"]
  Success2 --> Step3["Step3: address 更新"]
  Step3 --> Complete["完了<br/>全て更新済み"]

  style Error fill:#f99,stroke:#f00
  style Inconsistent fill:#fcc,stroke:#f00

図で理解できる要点:

  • エラーが発生すると、それ以前の更新は残ってしまう
  • ロールバック機能がないため、不整合な状態が放置される
  • 全て成功か全て失敗かを保証する仕組みが必要

解決策

トランザクション管理の設計パターン

トランザクション的な更新を実現するには、以下の 3 つの要素を組み合わせた設計パターンが有効です。

それは、スナップショット(更新前の状態保存)、検証フェーズ(更新可能性の事前確認)、コミット/ロールバック(適用または復元)という流れです。

トランザクション管理のフロー図

以下の図は、トランザクション管理を導入した場合の更新フローを示しています。

mermaidflowchart TD
  Start["更新開始"] --> Snapshot["スナップショット作成<br/>現在の状態を保存"]
  Snapshot --> Validate["検証フェーズ<br/>全ての更新を検証"]

  Validate --> ValidOK["検証成功"]
  Validate --> ValidNG["検証失敗"]

  ValidOK --> Apply["一括適用<br/>全ての更新をコミット"]
  Apply --> Complete["完了<br/>整合性を保持"]

  ValidNG --> Rollback["ロールバック<br/>スナップショットから復元"]
  Rollback --> RestoreState["元の状態に復元"]

  style Validate fill:#ff9,stroke:#cc0
  style Rollback fill:#9cf,stroke:#06c
  style Complete fill:#9f9,stroke:#0c0

図で理解できる要点:

  • 更新前に必ず現在の状態を保存する
  • 全ての更新内容を事前に検証してから適用する
  • エラー時は保存した状態に確実に戻せる

トランザクションマネージャーの実装

トランザクション機能を提供するヘルパークラスを作成します。このクラスは、スナップショット管理、検証、適用、ロールバックの責務を持ちます。

型定義とインターフェース

typescript// トランザクションの状態を表す型
type TransactionState<T> = {
  snapshot: T | null; // 更新前の状態
  updates: Partial<T>; // 適用予定の更新内容
  validated: boolean; // 検証済みフラグ
  committed: boolean; // コミット済みフラグ
};
typescript// 検証関数の型定義
type ValidatorFn<T> = (
  updates: Partial<T>,
  currentState: T
) => Promise<boolean> | boolean;
typescript// トランザクション設定オプション
interface TransactionOptions<T> {
  validators?: ValidatorFn<T>[]; // 検証関数の配列
  onCommit?: (state: T) => void; // コミット時のコールバック
  onRollback?: (state: T) => void; // ロールバック時のコールバック
  partial?: boolean; // 部分適用を許可するか
}

トランザクションマネージャークラス

typescriptimport { Store } from 'pinia';

export class TransactionManager<
  T extends Record<string, any>
> {
  private state: TransactionState<T>;
  private store: Store;
  private options: TransactionOptions<T>;

  constructor(
    store: Store,
    options: TransactionOptions<T> = {}
  ) {
    this.store = store;
    this.options = options;
    this.state = {
      snapshot: null,
      updates: {},
      validated: false,
      committed: false,
    };
  }
}

このクラスは、ジェネリック型 T を使って任意の store の型に対応できるようにしています。

スナップショット作成メソッド

typescript// TransactionManager クラス内
public begin(): void {
  // 現在の store の状態をディープコピーで保存
  this.state.snapshot = JSON.parse(
    JSON.stringify(this.store.$state)
  )
  this.state.updates = {}
  this.state.validated = false
  this.state.committed = false
}

begin メソッドは、トランザクション開始時に現在の状態をスナップショットとして保存します。JSON を使ったディープコピーで、ネストしたオブジェクトも正しくコピーされます。

更新内容の設定メソッド

typescript// TransactionManager クラス内
public set(updates: Partial<T>): void {
  if (this.state.committed) {
    throw new Error('トランザクションは既にコミット済みです')
  }

  // 更新内容をマージ
  this.state.updates = {
    ...this.state.updates,
    ...updates
  }

  // 新しい更新が追加されたら検証フラグをリセット
  this.state.validated = false
}

set メソッドで更新したい内容を設定します。複数回呼び出すことで、段階的に更新内容を追加できます。

検証メソッド

typescript// TransactionManager クラス内
public async validate(): Promise<boolean> {
  const { validators } = this.options

  // 検証関数が設定されていない場合は常に成功
  if (!validators || validators.length === 0) {
    this.state.validated = true
    return true
  }

  try {
    // 全ての検証関数を実行
    for (const validator of validators) {
      const result = await validator(
        this.state.updates,
        this.store.$state as T
      )

      if (!result) {
        this.state.validated = false
        return false
      }
    }

    this.state.validated = true
    return true
  } catch (error) {
    this.state.validated = false
    return false
  }
}

validate メソッドは、設定された検証関数を順次実行します。1 つでも失敗すると false を返し、全て成功すると true を返します。

コミットメソッド

typescript// TransactionManager クラス内
public async commit(): Promise<void> {
  // 検証が未実施または失敗している場合はエラー
  if (!this.state.validated) {
    const isValid = await this.validate()
    if (!isValid) {
      throw new Error('検証に失敗したためコミットできません')
    }
  }

  // store に更新を適用
  this.store.$patch(this.state.updates)

  this.state.committed = true

  // コミット時のコールバックを実行
  if (this.options.onCommit) {
    this.options.onCommit(this.store.$state as T)
  }
}

commit メソッドは、検証済みの更新内容を store に適用します。$patch を使うことで、リアクティブシステムへの通知を最小限に抑えています。

ロールバックメソッド

typescript// TransactionManager クラス内
public rollback(): void {
  if (!this.state.snapshot) {
    throw new Error('スナップショットが存在しません')
  }

  // スナップショットから状態を復元
  this.store.$patch(this.state.snapshot)

  // 状態をリセット
  this.state.updates = {}
  this.state.validated = false
  this.state.committed = false

  // ロールバック時のコールバックを実行
  if (this.options.onRollback) {
    this.options.onRollback(this.store.$state as T)
  }
}

rollback メソッドは、保存しておいたスナップショットから状態を復元します。エラーが発生した場合や、明示的にキャンセルしたい場合に使用します。

部分適用モードの実装

部分適用モードでは、検証に失敗した項目をスキップして、成功した項目だけを適用します。

typescript// TransactionManager クラス内
public async commitPartial(): Promise<{
  succeeded: string[]
  failed: string[]
}> {
  const succeeded: string[] = []
  const failed: string[] = []

  // 各プロパティごとに検証と適用を試みる
  for (const [key, value] of Object.entries(this.state.updates)) {
    try {
      const partialUpdate = { [key]: value }

      // 個別に検証
      if (this.options.validators) {
        let isValid = true
        for (const validator of this.options.validators) {
          const result = await validator(
            partialUpdate as Partial<T>,
            this.store.$state as T
          )
          if (!result) {
            isValid = false
            break
          }
        }

        if (!isValid) {
          failed.push(key)
          continue
        }
      }

      // 適用
      this.store.$patch(partialUpdate)
      succeeded.push(key)
    } catch (error) {
      failed.push(key)
    }
  }

  return { succeeded, failed }
}

commitPartial メソッドは、各プロパティを個別に検証・適用し、成功したプロパティと失敗したプロパティのリストを返します。

具体例

ユーザープロフィール更新の実装

実際のユースケースとして、ユーザープロフィールの更新処理を実装してみましょう。

Store の定義

typescriptimport { defineStore } from 'pinia';

export interface UserProfile {
  name: string;
  email: string;
  age: number;
  address: string;
  phoneNumber: string;
}

export const useUserStore = defineStore('user', {
  state: (): UserProfile => ({
    name: '',
    email: '',
    age: 0,
    address: '',
    phoneNumber: '',
  }),
});

検証関数の定義

typescript// メールアドレスの検証
const validateEmail: ValidatorFn<UserProfile> = async (
  updates
) => {
  if (!updates.email) return true;

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(updates.email)) {
    throw new Error(
      'メールアドレスの形式が正しくありません'
    );
  }

  // API で重複チェック(例)
  const isDuplicate = await checkEmailDuplicate(
    updates.email
  );
  if (isDuplicate) {
    throw new Error(
      'このメールアドレスは既に使用されています'
    );
  }

  return true;
};
typescript// 年齢の検証
const validateAge: ValidatorFn<UserProfile> = (updates) => {
  if (updates.age === undefined) return true;

  if (updates.age < 0 || updates.age > 150) {
    throw new Error(
      '年齢は 0 から 150 の範囲で入力してください'
    );
  }

  return true;
};
typescript// 電話番号の検証
const validatePhoneNumber: ValidatorFn<UserProfile> = (
  updates
) => {
  if (!updates.phoneNumber) return true;

  const phoneRegex = /^0\d{9,10}$/;
  if (!phoneRegex.test(updates.phoneNumber)) {
    throw new Error('電話番号の形式が正しくありません');
  }

  return true;
};

Composable としての提供

typescriptimport { computed } from 'vue';
import { useUserStore } from '@/stores/user';

export function useUserTransaction() {
  const userStore = useUserStore();

  const createTransaction = () => {
    return new TransactionManager<UserProfile>(userStore, {
      validators: [
        validateEmail,
        validateAge,
        validatePhoneNumber,
      ],
      onCommit: (state) => {
        console.log('プロフィールが更新されました:', state);
      },
      onRollback: (state) => {
        console.log('更新がキャンセルされました:', state);
      },
    });
  };

  return {
    userStore,
    createTransaction,
  };
}

Composable パターンを使うことで、コンポーネントから簡単にトランザクション機能を利用できます。

コンポーネントでの使用例(一括更新)

typescript<script setup lang="ts">
import { ref } from 'vue'
import { useUserTransaction } from '@/composables/useUserTransaction'

const { createTransaction } = useUserTransaction()

const formData = ref({
  name: '田中太郎',
  email: 'tanaka@example.com',
  age: 30,
  address: '東京都渋谷区',
  phoneNumber: '09012345678'
})

const updateProfile = async () => {
  const transaction = createTransaction()

  try {
    // トランザクション開始
    transaction.begin()

    // 更新内容を設定
    transaction.set(formData.value)

    // コミット(検証と適用)
    await transaction.commit()

    alert('プロフィールを更新しました')
  } catch (error) {
    // エラー時は自動的にロールバック
    transaction.rollback()
    alert(`更新に失敗しました: ${error.message}`)
  }
}
</script>

このコードでは、エラーが発生すると自動的にロールバックが実行され、更新前の状態に戻ります。

コンポーネントでの使用例(部分適用)

typescriptconst updateProfilePartial = async () => {
  const transaction = createTransaction();

  try {
    transaction.begin();
    transaction.set(formData.value);

    // 部分適用モードでコミット
    const result = await transaction.commitPartial();

    if (result.failed.length > 0) {
      alert(
        `一部の項目が更新できませんでした:\n` +
          result.failed.join(', ')
      );
    } else {
      alert('全ての項目を更新しました');
    }
  } catch (error) {
    transaction.rollback();
    alert(
      `更新処理でエラーが発生しました: ${error.message}`
    );
  }
};

部分適用モードでは、成功した項目だけが適用され、失敗した項目のリストが返されます。

フォームコンポーネントの実装例

vue<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-group">
      <label for="name">名前</label>
      <input
        id="name"
        v-model="formData.name"
        type="text"
        required
      />
    </div>

    <div class="form-group">
      <label for="email">メールアドレス</label>
      <input
        id="email"
        v-model="formData.email"
        type="email"
        required
      />
    </div>

    <div class="form-group">
      <label for="age">年齢</label>
      <input
        id="age"
        v-model.number="formData.age"
        type="number"
        required
      />
    </div>

    <div class="form-actions">
      <button type="submit">更新</button>
      <button type="button" @click="handlePartialUpdate">
        部分更新
      </button>
      <button type="button" @click="handleCancel">
        キャンセル
      </button>
    </div>
  </form>
</template>
typescript<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserTransaction } from '@/composables/useUserTransaction'

const { userStore, createTransaction } = useUserTransaction()
const formData = ref({ ...userStore.$state })
let transaction = createTransaction()

onMounted(() => {
  // コンポーネントマウント時にトランザクション開始
  transaction.begin()
})

const handleSubmit = async () => {
  try {
    transaction.set(formData.value)
    await transaction.commit()
    alert('更新が完了しました')
  } catch (error) {
    transaction.rollback()
    alert(`エラー: ${error.message}`)
  }
}

const handlePartialUpdate = async () => {
  try {
    transaction.set(formData.value)
    const result = await transaction.commitPartial()
    console.log('成功:', result.succeeded)
    console.log('失敗:', result.failed)
  } catch (error) {
    transaction.rollback()
  }
}

const handleCancel = () => {
  transaction.rollback()
  formData.value = { ...userStore.$state }
}
</script>

実装の全体構造図

以下の図は、実装した各要素の関係性を示しています。

mermaidflowchart TD
  Component["Vue コンポーネント"] --> Composable["useUserTransaction"]
  Composable --> TxManager["TransactionManager"]
  Composable --> Store["Pinia Store"]

  TxManager --> Snapshot["スナップショット管理"]
  TxManager --> Validator["検証ロジック"]
  TxManager --> Commit["コミット処理"]
  TxManager --> Rollback["ロールバック処理"]

  Validator --> ValidateEmail["メール検証"]
  Validator --> ValidateAge["年齢検証"]
  Validator --> ValidatePhone["電話番号検証"]

  Commit --> Store
  Rollback --> Store

  Store --> State["状態<br/>name, email, age, etc"]

図で理解できる要点:

  • Composable がトランザクションマネージャーと Store を橋渡しする
  • 検証ロジックは個別の関数として分離され、再利用可能
  • コミットとロールバックは Store の状態を直接操作する

エラーハンドリングのベストプラクティス

typescript// エラーの種類を定義
class ValidationError extends Error {
  constructor(message: string, public field: string) {
    super(message);
    this.name = 'ValidationError';
  }
}
typescript// 詳細なエラーハンドリング
const updateWithDetailedErrorHandling = async () => {
  const transaction = createTransaction();

  try {
    transaction.begin();
    transaction.set(formData.value);
    await transaction.commit();
  } catch (error) {
    transaction.rollback();

    if (error instanceof ValidationError) {
      // 検証エラーの場合、該当フィールドにフォーカス
      alert(`${error.field}の入力内容に問題があります`);
      document.getElementById(error.field)?.focus();
    } else if (error.message.includes('ネットワーク')) {
      // ネットワークエラーの場合
      alert('通信エラーが発生しました。再度お試しください');
    } else {
      // その他のエラー
      console.error('予期しないエラー:', error);
      alert('更新に失敗しました');
    }
  }
};

エラーの種類に応じて適切な処理を行うことで、ユーザーエクスペリエンスが向上します。

まとめ

本記事では、Pinia でトランザクション的な状態更新を実現するための設計パターンと実装方法をご紹介しました。

標準の Pinia にはトランザクション機能は備わっていませんが、スナップショット、検証、コミット、ロールバックという 4 つの要素を組み合わせた TransactionManager クラスを実装することで、データの整合性を保ちながら柔軟な状態管理が可能になります。

一括 set による複数プロパティの同時更新、部分適用モードによる柔軟な更新制御、そしてエラー時のロールバック機能により、複雑なフォーム処理や多段階の業務処理でも安全に状態を管理できるようになりました。

実装のポイントをまとめると、以下の表のようになります。

#項目内容
1スナップショット更新前に必ず現在の状態を保存する
2検証フェーズ全ての更新内容を事前に検証してから適用する
3一括コミット$patch を使って効率的に一括適用する
4ロールバックエラー時は保存した状態に確実に戻す
5部分適用成功した項目だけを選択的に適用できる
6エラーハンドリングエラーの種類に応じた適切な処理を行う

この設計パターンは、ユーザー登録フォーム、商品注文処理、設定画面など、さまざまな場面で活用できます。ぜひ皆さんのプロジェクトでも試してみてください。

状態管理の整合性を保つことは、アプリケーションの品質向上に直結する重要な要素ですので、今回ご紹介したトランザクション管理のパターンが、より堅牢な Vue アプリケーション開発の一助となれば幸いです。

関連リンク