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

React 入力フォームを作る③ 入力を保持するReactフォーム

Reactのフォームでは、入力中の値と、最後に保存できた値を分けて持ちます。

失敗したときに入力を残し、成功したときだけ保存済みの基準を更新すると、利用者がどこまで保存できたかを把握しやすくなります。

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

第1回のprofileTypes.tsを使います。通信関数は第4回、比較用コンポーネントは第5回で実装します。以下はそれらと組み合わせるフォーム本体です。

フォームが持つ状態を整理する

state意味
base編集の基準になっている保存済みデータとVersion
draft利用者が編集中の文字列
errors項目ごとの検証エラー
busy保存リクエストの処理中
blocked最新状態の確認が必要で、そのまま保存できない状態

dirtyは、baseから作った入力値とdraftが違うかを毎回計算します。別のstateとして持つと、入力変更時に更新し忘れる可能性があるためです。

フォーム本体を作る

ProfileForm.tsx
import { useId, useRef, useState, type FormEvent } from 'react';
import { SaveError, toDraft, type Draft, type FieldErrors, type Profile, type SaveInput } from './profileTypes';
import { ProfileComparison } from './ProfileComparison';

type Props = {
  initial: Profile;
  save: (input: SaveInput) => Promise<Profile>;
  loadLatest: () => Promise<Profile>;
};
export function ProfileForm({ initial, save, loadLatest }: Props) {
  const [base, setBase] = useState(initial);
  const [draft, setDraft] = useState(() => toDraft(initial));
  const [errors, setErrors] = useState<FieldErrors>({});
  const [message, setMessage] = useState('');
  const [busy, setBusy] = useState(false);
  const [blocked, setBlocked] = useState(false);
  const running = useRef(false);
  const summary = useRef<HTMLParagraphElement>(null);
  const prefix = useId();
  const original = toDraft(base);
  const dirty = (Object.keys(draft) as (keyof Draft)[]).some(key => draft[key] !== original[key]);

  function change(key: keyof Draft, value: string) {
    setDraft(current => ({ ...current, [key]: value }));
    setErrors(current => ({ ...current, [key]: undefined }));
  }
  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (running.current || blocked || !dirty) return;
    running.current = true;
    setBusy(true); setErrors({}); setMessage('');
    try {
      const saved = await save({ ...draft, expectedVersion: base.version });
      setBase(saved); setDraft(toDraft(saved));
      setMessage('保存しました。');
    } catch (error) {
      const failure = error instanceof SaveError ? error : new SaveError('unknown', '保存結果を確認できませんでした。');
      setErrors(failure.fields); setMessage(failure.message);
      setBlocked(failure.kind !== 'invalid');
      summary.current?.focus();
    } finally {
      running.current = false; setBusy(false);
    }
  }
  const labels = { displayName: '表示名', bio: '自己紹介', weeklyGoal: '週の目標日数(空欄可)' };
  return (
    <>
      <form noValidate onSubmit={submit}>
        <fieldset disabled={busy || blocked}>
          <legend>プロフィール</legend>
          {(Object.keys(labels) as (keyof Draft)[]).map(key => {
            const id = `${prefix}-${key}`;
            const fieldErrors = errors[key] ?? [];
            const common = {
              id, name: key, value: draft[key],
              'aria-invalid': fieldErrors.length > 0,
              'aria-describedby': fieldErrors.length ? `${id}-error` : undefined,
            };
            return <div key={key}>
              <label htmlFor={id}>{labels[key]}</label>
              {key === 'bio'
                ? <textarea {...common} maxLength={300} onChange={e => change(key, e.target.value)} />
                : <input {...common} type="text" inputMode={key === 'weeklyGoal' ? 'numeric' : 'text'}
                    maxLength={key === 'displayName' ? 40 : 1} onChange={e => change(key, e.target.value)} />}
              {fieldErrors.length > 0 && <p id={`${id}-error`}>{fieldErrors.join(' ')}</p>}
            </div>;
          })}
          <button type="submit" disabled={!dirty}>{busy ? '保存中…' : '保存する'}</button>
        </fieldset>
        <p ref={summary} tabIndex={-1} role="status">{message}</p>
        {dirty && <p>未保存の変更があります。</p>}
      </form>
      {blocked && <ProfileComparison draft={draft} loadLatest={loadLatest}
        onResolve={(latest, keepDraft) => {
          setBase(latest);
          if (!keepDraft) setDraft(toDraft(latest));
          setErrors({}); setMessage('内容を確認してから保存してください。'); setBlocked(false);
        }} />}
    </>
  );
}

saveとloadLatestをpropsで受け取ります。フォーム本体はURLやCookieの詳細を知らず、入力の保持と表示を担当します。

initialは初回表示の値として使います。取得するたびにEffectで入力を初期化する処理は置いていません。バックグラウンドで取得したデータが、編集途中の入力を上書きしないようにするためです。

数字の入力も文字列のまま保持する

目標日数の入力はtype="text"とinputMode="numeric"を使います。数字向けのキーボードを案内しながら、stateには文字列を保存します。

inputModeは入力の正しさを保証する属性ではありません。貼り付けやAPIへの直接送信もあるため、サーバー側の検証が必要です。

この例は項目数が少ないので、ラベルと入力の描画を小さなループにまとめています。日付、選択肢、複雑な説明が増えたら、無理に同じ描画へ押し込まず項目別の部品へ分けてください。

送信中は入力も止める

保存中はfieldsetを無効にし、入力とボタンを操作できなくします。

保存中も編集できると、「送った値」「送信後に入力した値」「サーバーから返った値」の3つを扱う必要があります。今回は、まず保存中の編集を止める方法を選びます。

runningのrefも使い、再描画前に続けて送信イベントが来た場合の重複を防ぎます。ただし、別タブの操作まで防げるわけではありません。

エラーを入力欄と結び付ける

入力欄のaria-invalidでエラーの有無を示し、aria-describedbyで項目エラーの文章へ関連付けます。useIdを使うので、同じページにフォームが複数あってもIDが衝突しにくくなります。

エラーが返ったら、フォーム全体のメッセージへフォーカスを移します。文字色だけで失敗を伝えるのではなく、メッセージと項目の対応を示します。

入力が変わった項目のエラーは消します。ただし、これは再検証に成功したという意味ではありません。古い値に対するメッセージを外し、次の保存でサーバーが再確認します。

成功時はサーバーが返した値を採用する

成功したら、baseとdraftの両方をレスポンスで更新します。

送信した値をそのまま成功値として使うと、サーバーで前後の空白を除いた結果が反映されません。Versionも古いまま残ってしまいます。

失敗時はdraftを変更しません。項目エラーならそのまま修正でき、競合や通信失敗なら最新値との比較に進みます。

noValidateはブラウザー標準の検証ポップアップを止め、このフォームのエラー表示へそろえるための指定です。maxLengthは入力補助として残しています。

次はAPI接続とエラー表示で、実際のHTTP通信をつなぎます。


関連記事