React 入力フォームを作る④ API接続とエラー表示
フォームからAPIを呼び出し、保存結果を画面へ返す処理を作ります。
入力エラーは項目の横へ、競合や通信失敗はフォーム全体へ伝えます。すべての失敗を「保存できませんでした」だけにすると、入力を直すべきか、再取得すべきかが分からなくなります。
エラーのキーを画面の項目へ対応させる
第2回のAPIは、入力エラーを次の形で返します。
入力エラーの例{
"status": 400,
"title": "入力内容を確認してください。",
"code": "validation_failed",
"errors": {
"displayName": ["表示名は1〜40文字で入力してください。"],
"weeklyGoal": ["目標は半角数字の1〜7、または空欄にしてください。"]
}
}画面は、知っている項目名に対応するエラーだけを欄の横へ表示します。expectedVersionやJSON解析エラーなど、欄へ対応しないものは全体のメッセージで扱います。
HTTP通信を1つのファイルにまとめる
profileApi.tsimport { 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や契約変更に備える必要がある場合は、スキーマで受信データを検証する方法を追加してください。
失敗の種類で、その後の操作を変える
| 分類 | 主な原因 | フォームの動作 |
|---|---|---|
invalid | 400の検証エラー | 入力を残し、修正して再送できる |
conflict | 409のVersion不一致 | 保存を止め、最新値と比較する |
session | 401の認証切れ | 入力を残し、再認証を案内する |
unknown | 通信失敗や想定外の応答 | 自動再送せず、保存結果を確認する |
400でも、壊れたJSONやCSRF検証の失敗などでは項目別エラーがありません。このコードは、その場合も全体メッセージを出します。入力しても直らない場合は、再取得や再ログインを案内し、開発者はログを調べます。
エラー形式全体の設計は、APIエラーの形式と処理を統一する方法も参考にしてください。
最初の取得とフォーム表示をつなぐ
ProfilePage.tsximport { 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へ保存する処理は追加していません。
次は保存競合と入力内容の保護で、最新値との比較画面を作ります。