· yuta

モーダルの保存処理はどこに書くべきか?ラベル作成モーダルの実装から考える「処理を親に置く」設計

#react#typescript#frontend

個人開発中のアプリで、ラベルを作成するモーダルを実装していました。実装自体は動いたのですが、コードを見返していて一つ疑問が浮かんだ箇所があったので調べてみました。

ラベルを作成する処理(API呼び出し)を、なぜわざわざ親のサイドバーに書いて、モーダルにpropsで渡しているのか?最初からモーダルの中に書けばいいのでは?

結論から言うと、モーダルの中に書いても動きます。どちらも間違いではなく、設計の考え方の違いです。ただ、親に置く形にはいくつかはっきりした利点があったので、そのとき整理した内容を備忘録として残しておきます。

今回の構成

ラベル作成まわりのコンポーネントは、次のような親子関係になっています。

LabelSidebar   ← ラベル一覧と「+」ボタン。モーダルの開閉状態を持つ
 └─ LabelModal ← ラベル名と色を入力するモーダル

「+」ボタンを押すとモーダルが開き、ラベル名と色を入力して「作成」を押すとAPIでラベルが作られ、モーダルが閉じる、という流れです。

実際の実装:処理は親、モーダルは入力だけ

親のLabelSidebarは、モーダルの開閉状態と、ラベルを作成する処理createLabelを持っています。

// LabelSidebar.tsx(抜粋)
export default function LabelSidebar() {
  const [isModalOpen, setIsModalOpen] = useState(false);
  const { addFlashMessage } = userUIStore();
  const { addLabel } = useLabelStore();

  const createLabel = async (name: string, color: string) => {
    try {
      const newLabel = await labelRepository.createLabel(name, color);
      addFlashMessage("ラベルを作成しました。", "success");
      addLabel(newLabel);
      setIsModalOpen(false);
    } catch (error) {
      console.error(error);
      addFlashMessage("ラベルの作成に失敗しました。", "error");
    }
  };

  return (
    <>
      {/* ラベル一覧と「+」ボタン */}
      {isModalOpen && (
        <LabelModal
          onClose={() => setIsModalOpen(false)}
          onSave={createLabel}
        />
      )}
    </>
  );
}

一方のLabelModalは、入力された値を管理して、ボタンが押されたら受け取った関数を呼ぶだけです。

// LabelModal.tsx(抜粋)
interface LabelModalProps {
  onClose: () => void;
  onSave: (name: string, color: string) => void;
}

export default function LabelModal({ onClose, onSave }: LabelModalProps) {
  const [name, setName] = useState("");
  const [selectedColor, setSelectedColor] = useState(LABEL_COLORS[0].value);

  return (
    // ...
    <button
      onClick={() => onSave(name.trim(), selectedColor)}
      disabled={!name.trim()}
    >
      作成
    </button>
    // ...
  );
}

ポイントは、LabelModalがAPIのこともストアのことも一切知らないことです。知っているのは「名前と色を入力してもらい、保存ボタンが押されたらonSaveを呼ぶ」ことだけ。保存したときに実際に何が起きるかは、親が決めています。

なぜ処理を親に置くのか

1. モーダルを閉じられるのは親だけだから

作成に成功したらモーダルを閉じたいのですが、モーダルが開いているかどうか(isModalOpen)を持っているのは親のLabelSidebarです。

仮にcreateLabelをモーダルの中に移したとしても、成功時に閉じるには、結局onCloseをpropsで受け取る必要があります。

// もしモーダル内に書いたら…
const handleSave = async () => {
  await labelRepository.createLabel(name, selectedColor);
  addLabel(newLabel);
  onClose(); // ← 閉じる処理は、どのみち親から借りることになる
};

「閉じる処理を親から借りる」のであれば、作成処理ごと親に置いてしまったほうが、成功時・失敗時の流れを1か所で追えて読みやすくなります。

2. 同じモーダルを「作成」にも「編集」にも使い回せるから

APIには、ラベルの作成(POST /labels)に加えて更新(PUT /labels/:id)も用意されています。いずれラベルの編集機能も作る予定です。

LabelModalは「保存ボタンでonSaveを呼ぶ」ことしか知らないので、渡す関数を変えるだけで編集にもそのまま使えます。

// 作成するとき
<LabelModal onSave={createLabel} onClose={closeModal} />

// 編集するとき(同じモーダルを使い回せる)
<LabelModal onSave={updateLabel} onClose={closeModal} />

もしモーダルの中にlabelRepository.createLabelを直接書いていたら、このモーダルは「作成専用」になります。編集に対応するには、もう1つ似たモーダルを作るか、mode === "edit"のような条件分岐をモーダルの中に足していくことになります。

3. 役割がはっきり分かれるから

処理を親に置くと、2つのコンポーネントの役割は次のように分かれます。

コンポーネント 役割
LabelModal 見た目と入力(ラベル名・色の入力欄、ボタン)
LabelSidebar 処理(API呼び出し、ストアへの追加、メッセージ表示、モーダルの開閉)

見た目の部品は使い回しやすく、処理は1か所にまとまります。不具合が起きたときも「APIまわりの問題なら親を見る」「表示の問題ならモーダルを見る」と、どこを探せばいいかがすぐ分かります。

データの流れを追う

「作成」ボタンが押されてからモーダルが閉じるまでの流れは、次のようになります。

LabelSidebar(isModalOpen と createLabel を保持)

LabelModal で「作成」をクリック
     │
     ▼
onSave(name, color) ──▶ createLabel(name, color)
                               │
                               ├─▶ labelRepository.createLabel()(API呼び出し)
                               ├─▶ addLabel()(ストアに追加)
                               ├─▶ addFlashMessage()(成功を通知)
                               └─▶ setIsModalOpen(false)(モーダルを閉じる)

子は「ボタンが押された」ことと「入力された値」を親に伝えるだけで、その後の処理はすべて親の中で完結しています。これはリフトアップの記事で整理した「状態は親が持ち、子は値とコールバックを受け取るだけ」という形と同じで、今回はそれを状態だけでなく処理(API呼び出しなどの副作用)にも当てはめた形になっています。

モーダルの中に書いたほうがいいケース

とはいえ、いつでも親に置くのが正解というわけではありません。次のようなケースでは、モーダルの中に処理を書いてしまったほうがシンプルです。

今回のアプリはラベルの編集機能を後から作る予定があるため、使い回しやすい今の形を選んでいます。「将来その部品を別の用途でも使うか?」が、判断の一つの目安になると思います。

まとめ

「動くからOK」で終わらせず、「この処理は誰の責任か?」を一度考えてみるだけで、後から機能を足すときの楽さがかなり変わると感じました。