はじめに

Reactでは、APIから取得したデータを useState に入れて扱う実装をよく見る。
小さな画面なら自然に見えるが、サーバー状態とローカル状態が二重化すると、再取得、保存、キャンセル、別ユーザー更新で破綻しやすい。

レビューで見たいのは、useState が悪いかどうかではない。
そのstateが表示用のコピーなのか、編集用のdraftなのか、キャッシュの代用品なのかである。

この記事では、サーバー状態をローカルstateへ複製する実装をどうレビューするか整理する。

まず止めたい実装

次のコードは、取得したユーザー一覧をローカルstateにコピーし、そのstateを表示・更新している。

サーバー状態をそのままコピーする例
export function UserListPage() {
  const { data: users, refetch } = useUsersQuery();
  const [items, setItems] = useState<User[]>([]);

  useEffect(() => {
    if (users) {
      setItems(users);
    }
  }, [users]);

  function renameUser(id: string, name: string) {
    setItems(current =>
      current.map(user =>
        user.id === id ? { ...user, name } : user
      )
    );
  }

  return <UserTable users={items} onRename={renameUser} />;
}

この実装では、usersitems のどちらが正なのか分からない。
refetch が走るたびに items が上書きされるため、編集中の名前が消える可能性もある。

Comment
@Reviewer: API取得結果をそのまま `items` にコピーしており、サーバー状態とローカル状態の所有者が二重化しています。表示用なら取得結果を直接使い、編集用ならdraftとして保存・破棄の責務を分けてください。

なぜ危険か

サーバー状態のコピーは、最初は便利に見える。

  • 並び替えしやすい
  • 一部だけ書き換えやすい
  • 入力中の値を保持しやすい
  • APIライブラリに依存しない形に見える

しかし、コピーした瞬間に同期責務が発生する。

  • 再取得時にローカル変更を上書きしてよいか
  • 保存成功後にどちらを更新するか
  • 保存失敗時にどちらへ戻すか
  • 別画面で同じデータを更新したら追従するか
  • キャッシュ更新とローカル更新が二重に走らないか

この責務が見えない実装は、レビューで止めたい。

レビュー観点1:表示用ならコピーしない

単に表示するだけなら、取得結果をそのまま渡す方がよい。

表示用データを直接使う例
export function UserListPage() {
  const { data: users = [], isLoading, error } = useUsersQuery();

  if (isLoading) {
    return <UserListSkeleton />;
  }

  if (error) {
    return <UserListError />;
  }

  return <UserTable users={users} />;
}

この形なら、サーバー状態の所有者は取得層にある。
Reactコンポーネントは、表示状態だけを扱えばよい。

Comment
@Reviewer: 表示だけに使うデータを `useState` へコピーする必要はなさそうです。再取得時の同期責務を増やさないため、取得結果をそのまま表示へ渡してください。

レビュー観点2:編集用ならdraftとして分ける

フォーム編集では、サーバー状態をそのまま書き換えるのではなく、編集用draftを持つことがある。

編集用draftを分ける例
export function UserEditForm({ user }: { user: User }) {
  const [draft, setDraft] = useState(() => ({
    name: user.name,
    role: user.role,
  }));

  function resetDraft() {
    setDraft({
      name: user.name,
      role: user.role,
    });
  }

  async function save() {
    await updateUser(user.id, draft);
  }

  return (
    <form>
      <input
        value={draft.name}
        onChange={event =>
          setDraft(current => ({ ...current, name: event.target.value }))
        }
      />
      <button type="button" onClick={save}>保存</button>
      <button type="button" onClick={resetDraft}>戻す</button>
    </form>
  );
}

この場合、draft はサーバー状態のコピーではなく「未保存の編集状態」である。
レビューでは、保存・破棄・再初期化の条件が明確かを見る。

レビュー観点3:props変更時にdraftを上書きしてよいか

編集用draftで特に危ないのは、親から渡る user が変わったときの同期である。

props変更で編集中内容を消す例
useEffect(() => {
  setDraft({
    name: user.name,
    role: user.role,
  });
}, [user]);

このコードは一見正しそうだが、編集中に再取得が起きると入力内容を消す。
レビューでは、どのタイミングならdraftを再初期化してよいかを確認する。

よくある方針は次の通り。

条件 draft再初期化
編集対象IDが変わった してよい
同じIDの再取得が起きた 注意が必要
保存成功後 サーバー応答で初期化してよい
キャンセル押下 明示操作なので戻してよい
対象ID変更だけで初期化する例
useEffect(() => {
  setDraft({
    name: user.name,
    role: user.role,
  });
}, [user.id]);

ただし、同じIDでもサーバー側で権限や状態が変わるケースはある。
その場合は、競合検知や確認ダイアログが必要になる。

レビュー観点4:派生データをstateにしていないか

サーバー状態から計算できる値をstateに持つと、同期漏れが起きる。

派生データをstateにしている例
const [activeUsers, setActiveUsers] = useState<User[]>([]);

useEffect(() => {
  setActiveUsers(users.filter(user => user.active));
}, [users]);

この程度なら、レンダリング中に計算するか useMemo で十分なことが多い。

派生データとして扱う例
const activeUsers = useMemo(
  () => users.filter(user => user.active),
  [users]
);

レビューでは、「stateにする理由」があるかを見る。
ユーザー操作で独立して変化しない値なら、stateではなく派生値として扱う方が安全である。

レビュー観点5:保存成功後の正をサーバー応答にしているか

更新APIの成功後、ローカルstateだけを書き換えて終わる実装は危ない。

ローカルだけ更新する例
async function saveName(id: string, name: string) {
  await updateUser(id, { name });
  setItems(current =>
    current.map(user =>
      user.id === id ? { ...user, name } : user
    )
  );
}

サーバー側では、名前の正規化、更新日時、権限に応じた表示項目の変更が起きるかもしれない。
成功後はサーバー応答で置き換えるか、キャッシュを再検証する方がレビューしやすい。

Comment
@Reviewer: 保存成功後にローカルstateだけを手で更新しています。サーバー側で正規化や関連値更新が入る可能性があるため、レスポンスで置き換えるか再取得で正に戻してください。

レビューコメント例

Comment
@Reviewer: `users` を `items` にコピーしているため、再取得、編集、保存のどれが正の状態か読み取れません。表示用データは取得結果を直接使い、編集が必要な部分だけdraftとして分けてください。
Comment
@Reviewer: `useEffect` でprops変更のたびにdraftを再初期化しているため、バックグラウンド再取得で入力中の内容が消える可能性があります。編集対象ID変更時だけ初期化するなど、上書き条件を明確にしてください。

まとめ

Reactでサーバー状態をローカルstateに複製すると、同期責務が増える。
レビューでは、stateの存在理由を確認したい。

  • 表示用ならコピーしない
  • 編集用ならdraftとして保存・破棄を設計する
  • props変更で入力中の値を消さない
  • 派生値をstateにしない
  • 保存成功後はサーバー応答を正にする

useState は便利だが、サーバー状態の代用品として使うと責務が曖昧になる。
レビューでは、どの状態が正なのかを言語化できる形へ寄せるのが重要である。