Reactのフォームでcontrolled/uncontrolledが混在する実装をどうレビューするか

Reactのフォームでは、入力値をstateで管理するcontrolled componentと、DOM側に値を持たせるuncontrolled componentのどちらも使える。
問題は、同じフォーム内で どちらが正なのか分からない状態 になることだ。

レビューで危ないのは、次のような実装である。

  • valuedefaultValue が同じ入力に混在している
  • propsの初期値を useState にコピーしたまま更新を追従しない
  • ref で読み取る値とReact stateの値が別々に存在する
  • reset時にDOMとstateの片方だけを戻している
  • 外部データ再取得時にフォームの編集状態が上書きされる

この記事ではフォームライブラリ比較ではなく、
入力値の所有者が一貫しているかをレビューで確認する観点を整理する。

まず止めたい実装

controlledとuncontrolledが混ざる例
type UserFormProps = {
  user: User;
  onSave: (user: User) => void;
};

export function UserForm({ user, onSave }: UserFormProps) {
  const [name, setName] = useState(user.name);
  const emailRef = useRef<HTMLInputElement>(null);

  return (
    <form
      onSubmit={event => {
        event.preventDefault();
        onSave({
          ...user,
          name,
          email: emailRef.current?.value ?? user.email,
        });
      }}
    >
      <input value={name} onChange={event => setName(event.target.value)} />
      <input defaultValue={user.email} ref={emailRef} />
      <button type="submit">保存</button>
    </form>
  );
}
Comment
@Reviewer: `name` はReact state、`email` はDOM refから取得しており、フォーム値の所有者が分かれています。保存、reset、外部更新時の挙動が揃わないため、フォーム全体でcontrolled/uncontrolledの方針を統一してください。

このコードは、保存ボタンを押すだけなら動く。
しかし、user が再取得されたとき、resetしたいとき、バリデーションしたいときに値の所在が分かれる。

レビューでは、入力欄が表示されるかではなく、
フォーム値を誰が持ち、いつ確定するのかを見る必要がある。

なぜ危ないのか

フォームの状態管理が曖昧だと、ユーザー操作の境界で破綻しやすい。

起きやすい問題は次の通りである。

  • 初期値が変わっても入力欄が更新されない
  • resetしても一部の項目だけ古い値が残る
  • バリデーション対象と送信対象の値が違う
  • 非同期保存後に画面表示と保存済みデータがずれる
  • 編集中の値が再取得データで突然上書きされる

controlledとuncontrolledのどちらが正しいかは状況による。
レビューで重要なのは、1つのフォーム内で責務が混ざっていないことである。

レビューで見たい3つの判断線

1. 入力値の正がReact stateなのかDOMなのか

controlledにするなら、送信、表示、バリデーション、resetの基準はReact stateになる。
DOM refから値を読む処理が混ざっている場合は、理由を確認したい。

Comment
@Reviewer: controlledな入力とref読み取りの入力が混在しています。バリデーションや保存時に参照する値がずれるため、React stateを正にするなら全項目をstate管理に寄せてください。

2. propsの初期値コピーが同期責務を持っているか

useState(props.value) は初回だけ評価される。
外部の user が変わっても自動では反映されない。

Comment
@Reviewer: `user.name` を `useState` の初期値にコピーしていますが、`user` が切り替わった時の同期方針がありません。編集対象IDが変わる場合は、フォーム状態を再初期化する条件を明示してください。

ただし、props更新のたびに入力値を上書きすると、編集中の内容を失う。
レビューでは、同期するかどうかだけでなく、どのイベントで再初期化するかを見る。

3. resetとsubmitが同じ状態を見ているか

保存時はstateを見るが、resetはDOM form resetに任せる、といった構造はずれやすい。
resetの責務も、値の所有者に合わせる。

Comment
@Reviewer: submitではReact stateを使っていますが、resetはDOMのdefaultValueに依存しています。reset後に送信される値と画面表示が一致するよう、resetも同じ状態管理方針に揃えてください。

改善例:controlledに寄せる

編集フォームでバリデーションや保存前確認を行うなら、controlledに寄せるとレビューしやすい。

controlledに統一したフォーム
type UserFormState = {
  name: string;
  email: string;
};

function toFormState(user: User): UserFormState {
  return {
    name: user.name,
    email: user.email,
  };
}

export function UserForm({ user, onSave }: UserFormProps) {
  const [form, setForm] = useState(() => toFormState(user));

  useEffect(() => {
    setForm(toFormState(user));
  }, [user.id]);

  function updateField(name: keyof UserFormState, value: string) {
    setForm(current => ({ ...current, [name]: value }));
  }

  return (
    <form
      onSubmit={event => {
        event.preventDefault();
        onSave({ ...user, ...form });
      }}
    >
      <input
        value={form.name}
        onChange={event => updateField("name", event.target.value)}
      />
      <input
        value={form.email}
        onChange={event => updateField("email", event.target.value)}
      />
      <button type="button" onClick={() => setForm(toFormState(user))}>
        リセット
      </button>
      <button type="submit">保存</button>
    </form>
  );
}

この構造なら、レビューアーは次を確認しやすい。

  • フォーム値の正がReact stateに揃っている
  • 編集対象IDが変わった時だけ再初期化される
  • resetとsubmitが同じ form を基準にしている
  • 入力欄ごとの値取得方法が統一されている
  • バリデーションを追加する位置が明確である

uncontrolledが妥当なケース

uncontrolledが悪いわけではない。
単純な検索フォームや、送信時だけ値を読めばよいフォームでは妥当なこともある。

送信時だけ読む検索フォーム
export function KeywordSearch({ onSearch }: { onSearch: (keyword: string) => void }) {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <form
      onSubmit={event => {
        event.preventDefault();
        onSearch(inputRef.current?.value ?? "");
      }}
    >
      <input ref={inputRef} name="keyword" />
      <button type="submit">検索</button>
    </form>
  );
}
Comment
@Reviewer: このフォームは送信時だけ値を読み、途中入力をReact stateや外部propsと同期していません。バリデーションや外部更新が不要なら、uncontrolledとして責務が閉じています。

重要なのは、uncontrolledならuncontrolledとして閉じることだ。

レビュー観点チェックリスト

Reactフォーム状態レビューの確認項目
  • 同じ入力に valuedefaultValue が混在していないか
  • フォーム値の正がReact stateかDOMか明確か
  • props初期値をstateへコピーした後の再初期化条件があるか
  • resetとsubmitが同じ状態を基準にしているか
  • refで読む値とstateで読む値が混在していないか
  • 外部データ再取得時に編集中の値を上書きしないか
  • controlled/uncontrolledの例外に理由があるか

まとめ

Reactのフォームレビューでは、controlledかuncontrolledかの好みではなく、入力値の所有者を読む。
特に 初期値コピー、reset、submit、外部更新 の4点で状態がずれていないかを確認したい。

フォームは正常系の入力だけなら動いて見える。
レビューでは、編集対象の切り替えや保存失敗後の再編集まで含めて、値の責務が一貫しているかを見る。