T-CREATOR

Svelte フォーム体験設計:Optimistic UI/エラー復旧/再送戦略の型

Svelte フォーム体験設計:Optimistic UI/エラー復旧/再送戦略の型

フォームの送信処理は、Web アプリケーションにおいて最も重要なユーザー体験の一つです。 送信ボタンを押してから結果が返ってくるまでの「待ち時間」をどう設計するかで、アプリケーションの品質が大きく変わります。

本記事では、Svelte を使ったフォームで「Optimistic UI(楽観的 UI)」「エラー復旧戦略」「再送戦略」を型安全に実装する方法を解説します。 これらのテクニックを組み合わせることで、ユーザーにストレスを与えない、信頼性の高いフォーム体験を実現できるでしょう。

背景

Web フォームにおける UX の課題

従来の Web フォームでは、送信ボタンを押した後に「ローディング表示」が出て、サーバーからのレスポンスを待つ必要がありました。 ネットワークが遅い環境では、この待ち時間が数秒に及ぶこともあります。

ユーザーは「本当に送信されたのか?」「もう一度押すべきか?」と不安になり、最悪の場合は重複送信が発生してしまうこともあるでしょう。

Optimistic UI という考え方

Optimistic UI は、サーバーからのレスポンスを待たずに、「成功するだろう」と楽観的に想定して UI を先に更新する手法です。 送信が成功する前提で画面を更新することで、体感速度が大幅に向上します。

ただし、実際には失敗する可能性もあるため、エラー時の復旧戦略を同時に設計する必要があります。

以下の図は、従来の UI と Optimistic UI の処理フローの違いを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant UI as UI
  participant API as API

  rect rgb(230, 240, 255)
    note right of User: 従来の UI
    User->>UI: フォーム送信
    UI->>API: POST リクエスト
    UI-->>User: ローディング表示
    API-->>UI: レスポンス
    UI-->>User: 結果表示
  end

  rect rgb(240, 255, 230)
    note right of User: Optimistic UI
    User->>UI: フォーム送信
    UI-->>User: 即座に結果表示
    UI->>API: POST リクエスト(バックグラウンド)
    API-->>UI: レスポンス
    alt 失敗時のみ
      UI-->>User: エラー表示 & 復旧
    end
  end

この図から分かる通り、Optimistic UI ではユーザーの待ち時間がほぼゼロになります。

Svelte の強み

Svelte は、リアクティブな状態管理と軽量なランタイムを持つフレームワークです。 フォームの状態管理をシンプルに記述でき、TypeScript との相性も良いため、型安全な Optimistic UI の実装に適しています。

課題

Optimistic UI 実装の難しさ

Optimistic UI を実装する際には、以下のような課題が生じます。

  1. 状態管理の複雑化: 送信中、成功、失敗の状態を適切に管理する必要があります
  2. エラー復旧: 失敗時に元の状態に戻す(ロールバック)処理が必要です
  3. 再送制御: ネットワークエラー時に自動再送するか、ユーザーに判断を委ねるか
  4. 型安全性: TypeScript で型を適切に定義しないと、実行時エラーが発生しやすくなります

具体的な問題シナリオ

以下のような状況を想定してみましょう。

  • ユーザーがコメントを投稿する
  • 楽観的に画面にコメントを表示する
  • サーバーへの送信が失敗する
  • ユーザーに適切なフィードバックを返し、再送を促す

このフローを型安全に、かつユーザーフレンドリーに実装するには、戦略的な設計が必要です。

以下の図は、フォーム送信時の状態遷移を示しています。

mermaidstateDiagram-v2
  [*] --> Idle: 初期状態
  Idle --> Submitting: フォーム送信
  Submitting --> OptimisticUpdate: 楽観的更新
  OptimisticUpdate --> Success: API 成功
  OptimisticUpdate --> Failed: API 失敗
  Failed --> Retrying: 再送試行
  Failed --> RolledBack: ロールバック
  Retrying --> Success: 再送成功
  Retrying --> Failed: 再送失敗
  Success --> Idle: 完了
  RolledBack --> Idle: 復旧完了

この状態遷移を適切に管理することが、Optimistic UI の鍵となります。

解決策

型定義による状態管理

まず、フォームの状態を型安全に定義します。 TypeScript の判別可能なユニオン型を使うことで、状態ごとに異なるプロパティを持たせることができます。

typescript// フォームの送信状態を表す型
type SubmissionState<T> =
  | { status: 'idle' }
  | { status: 'submitting'; optimisticData: T }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; originalData?: T }
  | {
      status: 'retrying';
      attempt: number;
      optimisticData: T;
    };

この型定義により、各状態で利用可能なプロパティが明確になります。 たとえば status'error' のときだけ error プロパティにアクセスできます。

Optimistic UI の基本実装

次に、Svelte のストアを使って Optimistic UI を実装します。 ストアは Svelte でリアクティブな状態を管理するための仕組みです。

typescriptimport { writable } from 'svelte/store';

// コメントの型定義
interface Comment {
  id: string;
  text: string;
  createdAt: Date;
}

// コメントリストのストア
const comments = writable<Comment[]>([]);

// 送信状態のストア
const submissionState = writable<SubmissionState<Comment>>({
  status: 'idle',
});

これらのストアを使って、データと送信状態を分離して管理します。

楽観的更新の実装

フォーム送信時に、まず楽観的にデータを更新します。 実際の API 呼び出しは非同期で行い、結果に応じて状態を変更します。

typescript// 楽観的コメント送信関数
async function submitCommentOptimistically(
  text: string
): Promise<void> {
  // 一時的な ID を生成(楽観的更新用)
  const optimisticComment: Comment = {
    id: `temp-${Date.now()}`,
    text,
    createdAt: new Date(),
  };

  // 楽観的に UI を更新
  comments.update((list) => [...list, optimisticComment]);
  submissionState.set({
    status: 'submitting',
    optimisticData: optimisticComment,
  });

  try {
    // API 呼び出し
    const savedComment = await postComment(text);

    // 成功時:一時 ID を正式な ID に置き換え
    comments.update((list) =>
      list.map((c) =>
        c.id === optimisticComment.id ? savedComment : c
      )
    );
    submissionState.set({
      status: 'success',
      data: savedComment,
    });
  } catch (error) {
    // エラー時の処理は次のセクションで実装
    handleSubmissionError(error, optimisticComment);
  }
}

この実装により、ユーザーは送信ボタンを押した瞬間にコメントが表示されます。

API 呼び出し関数

実際にサーバーと通信する関数を定義します。 エラーハンドリングも含めて実装しましょう。

typescript// API 呼び出し関数
async function postComment(text: string): Promise<Comment> {
  const response = await fetch('/api/comments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });

  if (!response.ok) {
    throw new Error(
      `HTTP Error: ${response.status} - ${response.statusText}`
    );
  }

  return await response.json();
}

この関数は HTTP ステータスコードが 200 番台以外の場合にエラーをスローします。

エラー復旧戦略

エラーが発生した場合、楽観的に追加したデータをロールバックする必要があります。 同時に、ユーザーに適切なフィードバックを提供します。

typescript// エラーハンドリング関数
function handleSubmissionError(
  error: unknown,
  optimisticComment: Comment
): void {
  const errorMessage =
    error instanceof Error
      ? error
      : new Error('Unknown error');

  // 楽観的に追加したコメントを削除(ロールバック)
  comments.update((list) =>
    list.filter((c) => c.id !== optimisticComment.id)
  );

  // エラー状態を設定
  submissionState.set({
    status: 'error',
    error: errorMessage,
    originalData: optimisticComment,
  });
}

このロールバック処理により、失敗時には何も起きなかったかのように UI が元に戻ります。

再送戦略の実装

ネットワークエラーなど一時的な問題の場合、自動的に再送を試みることができます。 ただし、無限ループを避けるために試行回数の上限を設けます。

typescript// 再送設定
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1000; // 1秒

// 再送機能付きの送信関数
async function submitWithRetry(
  text: string,
  attempt: number = 1
): Promise<void> {
  const optimisticComment: Comment = {
    id: `temp-${Date.now()}`,
    text,
    createdAt: new Date(),
  };

  // 初回送信時のみ楽観的更新
  if (attempt === 1) {
    comments.update((list) => [...list, optimisticComment]);
  }

  submissionState.set({
    status: attempt === 1 ? 'submitting' : 'retrying',
    optimisticData: optimisticComment,
    ...(attempt > 1 && { attempt }),
  });

  try {
    const savedComment = await postComment(text);

    comments.update((list) =>
      list.map((c) =>
        c.id === optimisticComment.id ? savedComment : c
      )
    );
    submissionState.set({
      status: 'success',
      data: savedComment,
    });
  } catch (error) {
    // 再送可能かチェック
    if (
      attempt < MAX_RETRY_ATTEMPTS &&
      isRetryableError(error)
    ) {
      // 指定時間待機後に再送
      await new Promise((resolve) =>
        setTimeout(resolve, RETRY_DELAY_MS * attempt)
      );
      return submitWithRetry(text, attempt + 1);
    }

    // 再送不可の場合はエラー処理
    handleSubmissionError(error, optimisticComment);
  }
}

この実装では、エラーの種類に応じて再送するかどうかを判定します。

再送可能なエラーの判定

すべてのエラーを再送すべきではありません。 ネットワークエラーやサーバーの一時的な問題のみ再送対象とします。

typescript// 再送可能なエラーかどうかを判定
function isRetryableError(error: unknown): boolean {
  if (!(error instanceof Error)) return false;

  // ネットワークエラー
  if (
    error.message.includes('NetworkError') ||
    error.message.includes('Failed to fetch')
  ) {
    return true;
  }

  // HTTP 5xx エラー(サーバー側の一時的な問題)
  if (error.message.includes('HTTP Error: 5')) {
    return true;
  }

  // HTTP 429 エラー(レート制限)
  if (error.message.includes('HTTP Error: 429')) {
    return true;
  }

  // それ以外(バリデーションエラーなど)は再送しない
  return false;
}

バリデーションエラーや認証エラーなど、再送しても解決しないエラーは除外します。

手動再送の実装

自動再送が失敗した場合、ユーザーが手動で再送できる機能も用意します。

typescript// 手動再送関数
function retrySubmission(): void {
  submissionState.update((state) => {
    if (state.status === 'error' && state.originalData) {
      // エラー状態から元のデータを取り出して再送
      const { originalData } = state;
      submitWithRetry(originalData.text);
      return {
        status: 'submitting',
        optimisticData: originalData,
      };
    }
    return state;
  });
}

この関数は、エラー状態に保存された元のデータを使って再送信を行います。

具体例

Svelte コンポーネントの実装

ここまでの型定義と関数を使って、実際の Svelte コンポーネントを作成します。 UI とロジックを統合した完全な例です。

svelte<script lang="ts">
  import { comments, submissionState, submitWithRetry, retrySubmission } from './commentStore'

  let inputText = ''

  // フォーム送信ハンドラー
  async function handleSubmit(event: Event) {
    event.preventDefault()

    if (!inputText.trim()) return

    await submitWithRetry(inputText)

    // 成功時のみ入力欄をクリア
    if ($submissionState.status === 'success') {
      inputText = ''
    }
  }
</script>

この <script> 部分では、フォーム送信のイベントハンドラーを定義しています。 送信が成功した場合のみ入力欄をクリアする点に注目してください。

svelte<!-- フォーム部分 -->
<form on:submit={handleSubmit}>
  <textarea
    bind:value={inputText}
    placeholder="コメントを入力してください"
    disabled={$submissionState.status === 'submitting' ||
              $submissionState.status === 'retrying'}
  />

  <button
    type="submit"
    disabled={$submissionState.status === 'submitting' ||
              $submissionState.status === 'retrying'}
  >
    {#if $submissionState.status === 'submitting'}
      送信中...
    {:else if $submissionState.status === 'retrying'}
      再送中({$submissionState.attempt}/{MAX_RETRY_ATTEMPTS}回目)
    {:else}
      送信
    {/if}
  </button>
</form>

フォームでは、送信中や再送中の状態に応じてボタンのテキストを動的に変更しています。 ユーザーは現在の状態を一目で把握できます。

svelte<!-- エラー表示部分 -->
{#if $submissionState.status === 'error'}
  <div class="error-banner">
    <p>送信に失敗しました: {$submissionState.error.message}</p>
    <button on:click={retrySubmission}>
      再送する
    </button>
  </div>
{/if}

エラーが発生した場合、エラーメッセージと再送ボタンを表示します。 ユーザーは自分のタイミングで再送を試すことができます。

svelte<!-- コメント一覧表示 -->
<ul class="comment-list">
  {#each $comments as comment (comment.id)}
    <li class:optimistic={comment.id.startsWith('temp-')}>
      <p>{comment.text}</p>
      <time>{comment.createdAt.toLocaleString()}</time>
    </li>
  {/each}
</ul>

コメント一覧では、一時 ID(temp- で始まる)を持つアイテムに対して特別なスタイルを適用できます。 楽観的に追加されたコメントを視覚的に区別することが可能です。

スタイリング例

楽観的に追加されたコメントを視覚的に区別するための CSS です。

css/* 楽観的コメントのスタイル */
.comment-list li.optimistic {
  opacity: 0.6;
  background-color: #f0f8ff;
  border-left: 3px solid #4a90e2;
}

/* エラーバナーのスタイル */
.error-banner {
  padding: 1rem;
  margin: 1rem 0;
  background-color: #fee;
  border: 1px solid #fcc;
  border-radius: 4px;
  color: #c33;
}

.error-banner button {
  margin-top: 0.5rem;
  padding: 0.5rem 1rem;
  background-color: #e33;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.error-banner button:hover {
  background-color: #c22;
}

これらのスタイルにより、ユーザーは現在の状態を直感的に理解できます。

完全なストア実装

これまでの関数をまとめた、完全なストアファイルの例です。

typescript// commentStore.ts
import { writable, derived } from 'svelte/store';

// 型定義
export interface Comment {
  id: string;
  text: string;
  createdAt: Date;
}

export type SubmissionState<T> =
  | { status: 'idle' }
  | { status: 'submitting'; optimisticData: T }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; originalData?: T }
  | {
      status: 'retrying';
      attempt: number;
      optimisticData: T;
    };

// 定数
export const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1000;

この部分では、エクスポートする型と定数を定義しています。

typescript// ストア
export const comments = writable<Comment[]>([]);
export const submissionState = writable<
  SubmissionState<Comment>
>({ status: 'idle' });

// 派生ストア(送信中かどうか)
export const isSubmitting = derived(
  submissionState,
  ($state) =>
    $state.status === 'submitting' ||
    $state.status === 'retrying'
);

派生ストア(derived)を使うことで、他のストアから計算された値をリアクティブに取得できます。

typescript// API 関数
async function postComment(text: string): Promise<Comment> {
  const response = await fetch('/api/comments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });

  if (!response.ok) {
    throw new Error(
      `HTTP Error: ${response.status} - ${response.statusText}`
    );
  }

  const data = await response.json();
  return {
    ...data,
    createdAt: new Date(data.createdAt),
  };
}

サーバーから返された日付文字列を Date オブジェクトに変換しています。

typescript// エラー判定関数
function isRetryableError(error: unknown): boolean {
  if (!(error instanceof Error)) return false;

  const message = error.message.toLowerCase();

  return (
    message.includes('networkerror') ||
    message.includes('failed to fetch') ||
    message.includes('http error: 5') ||
    message.includes('http error: 429')
  );
}

エラーメッセージを小文字に変換してから判定することで、大文字小文字の違いを吸収しています。

typescript// エラーハンドリング
function handleSubmissionError(
  error: unknown,
  optimisticComment: Comment
): void {
  const errorMessage =
    error instanceof Error
      ? error
      : new Error('Unknown error');

  comments.update((list) =>
    list.filter((c) => c.id !== optimisticComment.id)
  );

  submissionState.set({
    status: 'error',
    error: errorMessage,
    originalData: optimisticComment,
  });
}

エラー時には楽観的に追加したコメントを削除し、元のデータを保存します。

typescript// メイン送信関数(再送機能付き)
export async function submitWithRetry(
  text: string,
  attempt: number = 1
): Promise<void> {
  const optimisticComment: Comment = {
    id: `temp-${Date.now()}-${Math.random()}`,
    text,
    createdAt: new Date(),
  };

  if (attempt === 1) {
    comments.update((list) => [...list, optimisticComment]);
  }

  submissionState.set({
    status: attempt === 1 ? 'submitting' : 'retrying',
    optimisticData: optimisticComment,
    ...(attempt > 1 && { attempt }),
  });

  try {
    const savedComment = await postComment(text);

    comments.update((list) =>
      list.map((c) =>
        c.id === optimisticComment.id ? savedComment : c
      )
    );

    submissionState.set({
      status: 'success',
      data: savedComment,
    });
  } catch (error) {
    if (
      attempt < MAX_RETRY_ATTEMPTS &&
      isRetryableError(error)
    ) {
      await new Promise((resolve) =>
        setTimeout(resolve, RETRY_DELAY_MS * attempt)
      );
      return submitWithRetry(text, attempt + 1);
    }

    handleSubmissionError(error, optimisticComment);
  }
}

// 手動再送関数
export function retrySubmission(): void {
  submissionState.update((state) => {
    if (state.status === 'error' && state.originalData) {
      submitWithRetry(state.originalData.text);
      return {
        status: 'submitting',
        optimisticData: state.originalData,
      };
    }
    return state;
  });
}

これで完全な Optimistic UI システムが完成しました。

フローチャートで理解する処理の流れ

以下の図は、ユーザーがフォームを送信してから完了するまでの全体的な処理フローを示しています。

mermaidflowchart TD
  Start["ユーザーがフォーム送信"] --> OptAdd["楽観的にコメント追加"]
  OptAdd --> SetSubmit["状態を submitting に設定"]
  SetSubmit --> CallAPI["API 呼び出し"]

  CallAPI -->|成功| Replace["一時 ID を正式 ID に置換"]
  Replace --> SetSuccess["状態を success に設定"]
  SetSuccess --> Done["完了"]

  CallAPI -->|失敗| CheckRetry{"再送可能?"}
  CheckRetry -->|Yes| WaitRetry["待機(指数バックオフ)"]
  WaitRetry --> CheckAttempt{"試行回数 < 上限?"}
  CheckAttempt -->|Yes| SetRetry["状態を retrying に設定"]
  SetRetry --> CallAPI

  CheckAttempt -->|No| Rollback["楽観的追加を削除"]
  CheckRetry -->|No| Rollback
  Rollback --> SetError["状態を error に設定"]
  SetError --> ShowUI["エラー UI 表示"]
  ShowUI --> UserRetry{"ユーザーが<br/>再送ボタン押下?"}
  UserRetry -->|Yes| CallAPI
  UserRetry -->|No| End["終了"]

  Done --> End

図で理解できる要点:

  • 楽観的更新は API 呼び出し前に実行される
  • 自動再送は試行回数の上限まで繰り返す
  • 失敗時はロールバックし、ユーザーに手動再送の選択肢を提供する

この図により、複雑な処理フローが一目で理解できます。

まとめ

本記事では、Svelte を使った Optimistic UI、エラー復旧、再送戦略の実装方法を解説しました。

重要なポイント:

  • 型安全性: TypeScript の判別可能なユニオン型で状態を管理することで、実行時エラーを防ぎます
  • 楽観的更新: ユーザー体験を向上させるために、サーバーのレスポンスを待たずに UI を更新します
  • エラー復旧: 失敗時には楽観的更新をロールバックし、元の状態に戻します
  • 再送戦略: ネットワークエラーなど一時的な問題には自動再送、その他は手動再送を提供します
  • 視覚的フィードバック: 楽観的コメント、送信中、エラーなどの状態を視覚的に区別します

これらのテクニックを組み合わせることで、ネットワークが不安定な環境でも快適に使えるフォームを実装できます。 ユーザーは待ち時間を感じることなく、スムーズにアプリケーションを操作できるでしょう。

また、型定義をしっかり行うことで、将来的なメンテナンス性も向上します。 状態の変化を追いやすく、バグを早期に発見できる設計になっているのです。

Optimistic UI は実装が複雑に見えるかもしれませんが、本記事で紹介したパターンを使えば、型安全で保守しやすいコードを書くことができます。 ぜひ、あなたのプロジェクトでも試してみてください。

関連リンク