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

React 入力フォームを作る① 入力値と保存値を設計する

入力欄の値をstateへ保存するところまではできても、APIと接続すると考えることが増えます。空欄をどう扱うか、保存後に表示をどう更新するか、別のタブで先に保存されたらどうするか、といった点です。

今回は、学習用アプリのプロフィール編集を例に、ReactとASP.NET Coreをまたぐフォームを5回に分けて作ります。

  1. 入力値と保存値を設計する(この記事)
  2. サーバーで入力を検証する
  3. 入力を保持するReactフォーム
  4. API接続とエラー表示
  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.ts
export 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.cs
using 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
  • frontend
    • src
      • features
        • profile
          • profileTypes.ts
          • profileApi.ts
          • ProfileForm.tsx
          • ProfilePage.tsx
          • ProfileComparison.tsx

この連載は、入力から保存結果の確認までをつなぐための実装例です。保存層のSQLは掲載しますが、DBドライバー固有の接続や行の読み取りコードは省略します。

次はサーバーで入力を検証するへ進みます。stateの基本操作を先に確認したい場合は、useStateで入力フォームを作成する方法を参照してください。


関連記事