はじめに
zustand(Reactの状態管理ライブラリ)で、複数のコンポーネントから開閉するダイアログを実装していたときに、ストアの設計で引っかかった点がありました。
「開閉フラグ」と「ダイアログに表示するデータ」を別々にストアへ持たせたところ、ダイアログ側で同じような判定を二重に書くことになり、どこか冗長だと感じていました。
この記事では、その違和感の正体と、状態の持ち方を見直して解消した方法をまとめます。
問題
前提となる要件は次のとおりです。
- ダイアログは1つだが、開く操作は複数のコンポーネントから行う
- ダイアログを開くときに、表示するデータもストアに格納する
最初は素直に、開閉フラグとデータをそれぞれストアに持たせました。
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 チェックも必要になります。
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つになるので、フラグとデータが食い違うこともなくなります。
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) を呼ぶだけです。データを渡さずに開く手段がないため、「開いているのにデータがない」状態はそもそも作れません。
function SomeButton({ item }: { item: Data }) {
const open = useDialogStore((s) => s.open);
return <button onClick={() => open(item)}>詳細を見る</button>;
}
null チェックはラッパーコンポーネントで1回だけ行う
ダイアログ側は、ストアを購読して null チェックするだけのラッパーと、Data 型を受け取って描画する中身に分けます。
// ストアを購読し、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 に到達した時点では常に開いた状態になります。
開閉の流れ
閉じるときの流れは次のとおりです。
-
DialogのonCloseからclose()が呼ばれ、dataがnullになる -
dataを購読しているMyDialogが再レンダリングされる -
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
