3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

zustandでダイアログの開閉フラグをやめてdataだけで管理する

3
Posted at

はじめに

zustand(Reactの状態管理ライブラリ)で、複数のコンポーネントから開閉するダイアログを実装していたときに、ストアの設計で引っかかった点がありました。

「開閉フラグ」と「ダイアログに表示するデータ」を別々にストアへ持たせたところ、ダイアログ側で同じような判定を二重に書くことになり、どこか冗長だと感じていました。

この記事では、その違和感の正体と、状態の持ち方を見直して解消した方法をまとめます。

問題

前提となる要件は次のとおりです。

  • ダイアログは1つだが、開く操作は複数のコンポーネントから行う
  • ダイアログを開くときに、表示するデータもストアに格納する

最初は素直に、開閉フラグとデータをそれぞれストアに持たせました。

dialogStore.ts
type DialogStore = {
  isOpen: boolean;
  data: Data | null;
  open: (data: Data) => void;
  close: () => void;
};

export const useDialogStore = create<DialogStore>((set) => ({
  isOpen: false,
  data: null,
  open: (data) => set({ isOpen: true, data }),
  close: () => set({ isOpen: false, data: null }),
}));

ダイアログは isOpen を見て開閉します。ところが data の型は Data | null なので、中身を表示するには null チェックも必要になります。

MyDialog.tsx
function MyDialog() {
  const isOpen = useDialogStore((s) => s.isOpen);
  const data = useDialogStore((s) => s.data);
  const close = useDialogStore((s) => s.close);

  return (
    <Dialog open={isOpen} onClose={close}>
      {/* isOpen で開閉を見ているのに、ここでも null チェックが要る */}
      {data !== null && data.title}
    </Dialog>
  );
}

ここでの違和感を言語化すると、次の2点になります。

1つ目は、同じ事実を2つの状態で表していることです。「ダイアログが開いている」と「表示するデータがある」は、この要件では常に一致するはずです。それなのに isOpen と data に分けて持っているため、判定も2回書くことになります。

2つ目は、型が実態を表していないことです。実際には「開いているのにデータが null」という状態は起こり得ません。しかし型の上では isOpen: true かつ data: null という組み合わせが許されてしまうため、TypeScriptは isOpen を確認しても data を Data 型に絞り込んでくれません。結果として、開いていると分かっていても null チェックを書かされます。

解決方法

開閉フラグをやめ、data の有無で開閉を表す

「開いている=データがある」なら、フラグを持たずに data だけで開閉状態を表せます。状態の出どころが1つになるので、フラグとデータが食い違うこともなくなります。

dialogStore.ts
type DialogStore = {
  data: Data | null;
  open: (data: Data) => void;
  close: () => void;
};

export const useDialogStore = create<DialogStore>((set) => ({
  data: null,
  open: (data) => set({ data }),
  close: () => set({ data: null }),
}));

呼び出し側は open(data) を呼ぶだけです。データを渡さずに開く手段がないため、「開いているのにデータがない」状態はそもそも作れません。

SomeButton.tsx
function SomeButton({ item }: { item: Data }) {
  const open = useDialogStore((s) => s.open);
  return <button onClick={() => open(item)}>詳細を見る</button>;
}

null チェックはラッパーコンポーネントで1回だけ行う

ダイアログ側は、ストアを購読して null チェックするだけのラッパーと、Data 型を受け取って描画する中身に分けます。

MyDialog.tsx
// ストアを購読し、null チェックを1か所にまとめるラッパー
function MyDialog() {
  const data = useDialogStore((s) => s.data);
  const close = useDialogStore((s) => s.close);

  if (data === null) return null;

  return <MyDialogContent data={data} onClose={close} />;
}

// data は必ず Data 型で受け取る
function MyDialogContent({
  data,
  onClose,
}: {
  data: Data;
  onClose: () => void;
}) {
  return (
    <Dialog open onClose={onClose}>
      {data.title}
    </Dialog>
  );
}

MyDialogContent は props に null が来ることを考えなくてよくなります。ストアにも依存しないため、単体で使ったりテストしたりもしやすくなります。

なお、ここでの Dialog はMUIなどのUIライブラリが提供するダイアログを想定しています。open は表示するかどうかを指定するプロパティで、値を省略して open と書くと open={true} と同じ意味です。手前で null の場合に return null しているので、Dialog に到達した時点では常に開いた状態になります。

開閉の流れ

閉じるときの流れは次のとおりです。

  1. Dialog の onClose から close() が呼ばれ、data が null になる
  2. data を購読している MyDialog が再レンダリングされる
  3. data === null なので return null となり、子の MyDialogContent がアンマウントされる

ポイントは、MyDialog 自体はマウントされたまま購読を続けていることです。消えるのは子の MyDialogContent だけなので、次に別のコンポーネントから open(data) が呼ばれると、子が改めてマウントされてダイアログが表示されます。

フラグを残したい場合は判別可能なユニオン型にする

どうしてもフラグを残したい場合は、判別可能なユニオン型(共通のプロパティの値によって型を絞り込めるTypeScriptのユニオン型)で「ありえる組み合わせ」だけを型にします。

type DialogState =
  | { isOpen: true; data: Data }
  | { isOpen: false; data: null };

こうすると if (state.isOpen) の中では data が Data 型に絞り込まれるため、null チェックを重ねる必要はなくなります。ただ、data だけで開閉を表せる今回の要件では、フラグをなくすほうが記述量は少なく済みます。

おわりに

「フラグを見ているのに、さらに null チェックも要る」という二度手間は、同じ事実を2つの状態で持っていたことと、型がありえない組み合わせを許していたことが原因でした。

状態を1つにまとめ、null チェックをラッパーコンポーネントの1か所に寄せることで、呼び出し側もダイアログの中身もシンプルになりました。ストアに状態を追加するときは、「それは既存の状態から導き出せないか」を一度考えてみると良さそうです。

参考

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?