React 入力フォームを作る③ 入力を保持するReactフォーム
Reactのフォームでは、入力中の値と、最後に保存できた値を分けて持ちます。
失敗したときに入力を残し、成功したときだけ保存済みの基準を更新すると、利用者がどこまで保存できたかを把握しやすくなります。
第1回のprofileTypes.tsを使います。通信関数は第4回、比較用コンポーネントは第5回で実装します。以下はそれらと組み合わせるフォーム本体です。
フォームが持つ状態を整理する
| state | 意味 |
|---|---|
base | 編集の基準になっている保存済みデータとVersion |
draft | 利用者が編集中の文字列 |
errors | 項目ごとの検証エラー |
busy | 保存リクエストの処理中 |
blocked | 最新状態の確認が必要で、そのまま保存できない状態 |
dirtyは、baseから作った入力値とdraftが違うかを毎回計算します。別のstateとして持つと、入力変更時に更新し忘れる可能性があるためです。
フォーム本体を作る
ProfileForm.tsximport { 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通信をつなぎます。