zukucode
主にWEB関連の情報を技術メモとして発信しています。

React 入力フォームを作る④ API接続とエラー表示

フォームからAPIを呼び出し、保存結果を画面へ返す処理を作ります。

入力エラーは項目の横へ、競合や通信失敗はフォーム全体へ伝えます。すべての失敗を「保存できませんでした」だけにすると、入力を直すべきか、再取得すべきかが分からなくなります。

  1. 入力値と保存値を設計する
  2. サーバーで入力を検証する
  3. 入力を保持するReactフォーム
  4. API接続とエラー表示(この記事)
  5. 保存競合と入力内容の保護

エラーのキーを画面の項目へ対応させる

第2回のAPIは、入力エラーを次の形で返します。

入力エラーの例
{
  "status": 400,
  "title": "入力内容を確認してください。",
  "code": "validation_failed",
  "errors": {
    "displayName": ["表示名は1〜40文字で入力してください。"],
    "weeklyGoal": ["目標は半角数字の1〜7、または空欄にしてください。"]
  }
}

画面は、知っている項目名に対応するエラーだけを欄の横へ表示します。expectedVersionやJSON解析エラーなど、欄へ対応しないものは全体のメッセージで扱います。

HTTP通信を1つのファイルにまとめる

profileApi.ts
import { SaveError, type FieldErrors, type Profile, type SaveInput } from './profileTypes';
const url = '/api/me/profile';

export function readFieldErrors(value: unknown): FieldErrors {
  const result: FieldErrors = {};
  if (!value || typeof value !== 'object') return result;
  const errors = (value as { errors?: unknown }).errors;
  if (!errors || typeof errors !== 'object') return result;
  for (const key of ['displayName', 'bio', 'weeklyGoal'] as const) {
    const messages = (errors as Record<string, unknown>)[key];
    if (Array.isArray(messages) && messages.every(m => typeof m === 'string'))
      result[key] = messages;
  }
  return result;
}

export async function getProfile(signal?: AbortSignal): Promise<Profile> {
  const response = await fetch(url, { credentials: 'same-origin', cache: 'no-store', signal });
  if (!response.ok) throw new Error('プロフィールを取得できませんでした。');
  return response.json() as Promise<Profile>;
}

export async function saveProfile(input: SaveInput): Promise<Profile> {
  try {
    const csrf = await fetch(`${url}/csrf`, { credentials: 'same-origin', cache: 'no-store' });
    if (csrf.status === 401) throw new SaveError('session', 'ログインし直してください。');
    if (!csrf.ok) throw new SaveError('unknown', '保存の準備に失敗しました。');
    const { token }: { token: string } = await csrf.json();
    const response = await fetch(url, {
      method: 'PUT', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token },
      body: JSON.stringify(input),
    });
    if (response.status === 400) {
      const body: unknown = await response.json().catch(() => null);
      throw new SaveError('invalid', '入力内容を確認してください。項目に表示がない場合は画面の再取得もお試しください。', readFieldErrors(body));
    }
    if (response.status === 409)
      throw new SaveError('conflict', '別の画面で更新されています。最新の内容と比較してください。');
    if (response.status === 401)
      throw new SaveError('session', 'ログインし直してください。');
    if (!response.ok) throw new SaveError('unknown', '保存結果を確認できませんでした。');
    return await response.json() as Profile;
  } catch (error) {
    if (error instanceof SaveError) throw error;
    throw new SaveError('unknown', '通信が完了しませんでした。再送する前に最新の内容を確認してください。');
  }
}

readFieldErrorsは、受信データがオブジェクトで、既知の項目に文字列の配列があるかを確認します。知らないキーを、そのまま任意のフォーム項目として追加しません。

今回、成功レスポンスは管理しているAPIの契約を前提に型を付けています。as Profileは実行時の検証ではありません。別チームのAPIや契約変更に備える必要がある場合は、スキーマで受信データを検証する方法を追加してください。

失敗の種類で、その後の操作を変える

分類主な原因フォームの動作
invalid400の検証エラー入力を残し、修正して再送できる
conflict409のVersion不一致保存を止め、最新値と比較する
session401の認証切れ入力を残し、再認証を案内する
unknown通信失敗や想定外の応答自動再送せず、保存結果を確認する

400でも、壊れたJSONやCSRF検証の失敗などでは項目別エラーがありません。このコードは、その場合も全体メッセージを出します。入力しても直らない場合は、再取得や再ログインを案内し、開発者はログを調べます。

エラー形式全体の設計は、APIエラーの形式と処理を統一する方法も参考にしてください。

最初の取得とフォーム表示をつなぐ

ProfilePage.tsx
import { useEffect, useState } from 'react';
import { ProfileForm } from './ProfileForm';
import { getProfile, saveProfile } from './profileApi';
import type { Profile } from './profileTypes';

export function ProfilePage() {
  const [profile, setProfile] = useState<Profile | null>(null);
  const [failed, setFailed] = useState(false);
  const [attempt, setAttempt] = useState(0);
  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;
    setFailed(false);
    getProfile(controller.signal)
      .then(value => { if (!ignore) setProfile(value); })
      .catch(() => { if (!ignore) setFailed(true); });
    return () => { ignore = true; controller.abort(); };
  }, [attempt]);
  if (profile) return <ProfileForm initial={profile} save={saveProfile} loadLatest={getProfile} />;
  if (failed) return <div><p>取得できませんでした。ログイン状態を確認してください。</p>
    <button type="button" onClick={() => setAttempt(n => n + 1)}>再取得する</button></div>;
  return <p>読み込み中…</p>;
}

取得前は空の編集フォームを出さず、読み込み中として表示します。取得に失敗した場合も、空欄を「保存済みの値」として扱いません。

Effectの後処理では古い取得結果を無視し、通信も中断します。画面を離れたあとに取得結果が届いても、その画面へ適用しないためです。

アプリの認証状態が変わった場合は、このページを破棄してください。たとえばログイン中の利用者を取得した親で、次のように利用者IDをkeyへ使います。

認証済みの親コンポーネントから表示する例
<ProfilePage key={currentUser.id} />

currentUserは既存の認証状態から取得します。別の利用者に切り替わった後へ、前の入力や取得結果を引き継いではいけません。

保存結果が不明なときに自動再送しない

通信失敗には、サーバーへ届かなかった場合と、保存後の応答だけが届かなかった場合があります。

fetchの失敗だけで「DBは更新されていない」と判断しないようにします。保存ボタンを止め、最新値を取得してから操作を続けてもらいます。

同様に、サーバーの保存後にレスポンスJSONの解析が失敗した場合も、保存結果は不明です。saveProfileではこのケースもunknownへまとめています。

未保存の入力を残すのはReactのstate内だけです。ページを閉じたり再読み込みしたりすると消えます。個人情報を含む入力を、断りなくlocalStorageへ保存する処理は追加していません。

次は保存競合と入力内容の保護で、最新値との比較画面を作ります。


関連記事