React 入力フォームを作る① 入力値と保存値を設計する
入力欄の値をstateへ保存するところまではできても、APIと接続すると考えることが増えます。空欄をどう扱うか、保存後に表示をどう更新するか、別のタブで先に保存されたらどうするか、といった点です。
今回は、学習用アプリのプロフィール編集を例に、ReactとASP.NET Coreをまたぐフォームを5回に分けて作ります。
今回作るフォーム
ログイン済みの利用者が、自分のプロフィールを編集する画面です。
| 項目 | 入力ルール | 保存する値 |
|---|---|---|
| 表示名 | 必須。前後の空白を除いて1〜40文字 | 文字列 |
| 自己紹介 | 任意。前後の空白を除いて300文字以内 | 文字列、空欄ならnull |
| 週の目標日数 | 任意。半角数字の1〜7 | 整数、空欄ならnull |
表示名はログインIDではありません。この例では同じ表示名の利用者も許可します。表示名の重複チェックや変更に伴う認証情報の更新は不要です。
会員登録時にプロフィールが作られている前提で、既存データの取得と更新だけを扱います。ログイン、会員作成、DB接続は既存のアプリに組み込みます。C#のコードは.NET 8、C# 12を対象にしています。
入力中の値を、そのまま保存用の型にしない
週の目標日数を、最初からnumberだけで管理すると、空欄を表せません。入力を消すたびに0へ変換すると、「未設定」と「0日」の違いも失われます。
画面では文字列として持ち、サーバーが検証してから整数へ変換します。
週の目標日数の流れ画面の入力 "3"
↓ JSONでは文字列として送信
サーバーで半角数字の1〜7かを検証
↓
DBには整数の3を保存空欄の""は、DBではnullにします。このフォームでは0を未設定の代わりには使いません。
JSON APIだから必ず数値で送る必要があるわけではありません。今回はフォーム専用の入力契約として、編集時の表現を文字列で受け取る設計を選びます。外部向けの汎用APIなら、数値型と入力変換を別に設計する方法もあります。
APIの入力と戻り値を決める
利用者自身のデータなので、URLは/api/me/profileにします。リクエストに保存先の利用者IDを入れず、サーバーが認証情報から決めます。
| メソッド | URL | 役割 |
|---|---|---|
| GET | /api/me/profile | 保存済みのプロフィールを取得する |
| GET | /api/me/profile/csrf | 更新に使うCSRFトークンを取得する |
| PUT | /api/me/profile | 編集可能な3項目をまとめて更新する |
取得結果は次の形です。
取得結果{
"displayName": "山田",
"bio": null,
"weeklyGoal": 3,
"version": "12"
}保存時には、入力欄の文字列と、取得時のVersionを送ります。
保存する本文{
"displayName": " 山田 ",
"bio": "",
"weeklyGoal": "3",
"expectedVersion": "12"
}versionは変更のたびに増える番号です。DBの大きな整数をJavaScriptの数値へ変換して精度を失わないよう、APIでは文字列で扱います。画面では加算せず、サーバーが返した値を次の保存へ渡します。
画面の型を分ける
次のファイルは、後続の記事でも共通で使います。
profileTypes.tsexport type Profile = {
displayName: string;
bio: string | null;
weeklyGoal: number | null;
version: string;
};
export type Draft = { displayName: string; bio: string; weeklyGoal: string };
export type SaveInput = Draft & { expectedVersion: string };
export type FieldErrors = Partial<Record<keyof Draft, string[]>>;
export const toDraft = (profile: Profile): Draft => ({
displayName: profile.displayName,
bio: profile.bio ?? '',
weeklyGoal: profile.weeklyGoal === null ? '' : String(profile.weeklyGoal),
});
export class SaveError extends Error {
constructor(
public kind: 'invalid' | 'conflict' | 'session' | 'unknown',
message: string,
public fields: FieldErrors = {},
) { super(message); }
}Profileが保存済みデータ、Draftが編集中の値、SaveInputが送信用のデータです。
toDraftでは、保存済みのnullを空文字へ戻します。Reactの入力欄にnullやundefinedを渡さず、常に文字列を渡せます。React公式のinputの説明にも、制御された入力の値を文字列として管理する注意点があります。
SaveErrorは、通信処理からフォームへ失敗の種類を伝えるためのクラスです。サーバー検証の失敗と、保存結果が分からない通信失敗を区別します。
サーバーの型も用途ごとに分ける
C#では入力、保存用の値、DBの行、レスポンスを分けます。
ProfileModels.csusing System.Globalization;
public sealed record ProfileRow(Guid UserId, string DisplayName, string? Bio,
int? WeeklyGoal, long Version);
public sealed record ProfileInput(string? DisplayName, string? Bio,
string? WeeklyGoal, string? ExpectedVersion);
public sealed record ProfileValues(string DisplayName, string? Bio, int? WeeklyGoal);
public sealed record ProfileResult(string DisplayName, string? Bio,
int? WeeklyGoal, string Version)
{
public static ProfileResult From(ProfileRow row) => new(row.DisplayName,
row.Bio, row.WeeklyGoal, row.Version.ToString(CultureInfo.InvariantCulture));
}
public interface IProfileStore
{
Task<ProfileRow?> FindAsync(Guid userId, CancellationToken ct);
Task<ProfileRow?> TrySaveAsync(Guid userId, long expectedVersion,
ProfileValues values, CancellationToken ct);
}IProfileStoreは、既存のDBアクセス層へ実装するインターフェースです。TrySaveAsyncは、Versionが一致した場合だけ保存し、保存した行を返します。条件に合う行がなければnullを返します。DB障害はnullにせず例外として扱います。
保存先の制約を決める
PostgreSQLでは、次のように表せます。利用者テーブルへの外部キーは、既存の会員テーブルに合わせて追加してください。
プロフィールの保存構造CREATE TABLE user_profile (
user_id uuid PRIMARY KEY,
display_name text NOT NULL CHECK (char_length(display_name) BETWEEN 1 AND 40),
bio text CHECK (bio IS NULL OR char_length(bio) <= 300),
weekly_goal integer CHECK (weekly_goal BETWEEN 1 AND 7),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0)
);DBにも基本の範囲制約を置きますが、入力者へ分かりやすいメッセージを返すため、APIでも検証します。
なお、C#とJavaScriptの文字列の長さはUTF-16の単位で数えます。絵文字などを、見た目の1文字として数える実装ではありません。PostgreSQLのchar_lengthとも数え方が異なるため、この例ではAPIの長さ制限を利用者向けの基準にします。
ファイル構成
- backend
- Profiles
- ProfileModels.cs
- ProfileValidation.cs
- ProfileController.cs
- Profiles
- frontend
- src
- features
- profile
- profileTypes.ts
- profileApi.ts
- ProfileForm.tsx
- ProfilePage.tsx
- ProfileComparison.tsx
- profile
- features
- src
この連載は、入力から保存結果の確認までをつなぐための実装例です。保存層のSQLは掲載しますが、DBドライバー固有の接続や行の読み取りコードは省略します。
次はサーバーで入力を検証するへ進みます。stateの基本操作を先に確認したい場合は、useStateで入力フォームを作成する方法を参照してください。