モーダルやダイアログの「開閉状態」どこで持つ問題、Jotai ならこう解決する

React 開発でモーダルやダイアログの開閉状態をどこで管理するか、この問題に悩まされた経験はありませんか?
親コンポーネントでuseState
を使って管理すると、props の受け渡しが複雑になり、Context を使うと今度は状態が散らばって管理が困難になる。そんなジレンマを抱えている開発者の方も多いのではないでしょうか。
今回は、Jotai を使ってモーダル・ダイアログの開閉状態をスマートに管理する方法をご紹介します。従来の手法では解決できなかった課題を、Jotai ならではのアプローチで解決していきましょう。
モーダル開閉状態管理の「よくある問題」
props ドリリング地獄
React 開発で最も頻繁に遭遇するのが、この props ドリリング問題です。
typescript// 😱 よくある問題のあるパターン
const App = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] =
useState(false);
return (
<Layout
onOpenModal={() => setIsModalOpen(true)}
onOpenConfirm={() => setIsConfirmOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
>
<Header
onOpenModal={() => setIsModalOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
/>
<MainContent
onOpenConfirm={() => setIsConfirmOpen(true)}
onOpenModal={() => setIsModalOpen(true)}
/>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
<ConfirmDialog
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
/>
<SettingsDialog
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
/>
</Layout>
);
};
このコードを見ると、親コンポーネントがモーダル管理の責任を一手に引き受けており、子コンポーネントにはモーダルを開くための props を延々と渡し続けています。
モーダルが増えるたびに、関連するすべてのコンポーネントで props の追加が必要になり、保守性が著しく低下してしまいますね。
Context 過多による複雑化
props ドリリングを解決しようとして、Context を使うパターンも一般的です。
typescript// 😰 Context乱立パターン
const ModalContext = createContext();
const ConfirmContext = createContext();
const SettingsContext = createContext();
const ModalProvider = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<ModalContext.Provider value={{ isOpen, setIsOpen }}>
{children}
</ModalContext.Provider>
);
};
const App = () => {
return (
<ModalProvider>
<ConfirmProvider>
<SettingsProvider>
<Layout />
</SettingsProvider>
</ConfirmProvider>
</ModalProvider>
);
};
一見解決したように見えますが、モーダルの種類が増えるたびに新しい Context と Providerが必要になります。
コンポーネントツリーが Provider で埋め尽くされ、どの Context がどの状態を管理しているのか把握が困難になってしまいます。
親コンポーネントの肥大化
最も深刻な問題は、親コンポーネントがモーダル管理のロジックで肥大化することです。
typescript// 😵 親コンポーネント肥大化の例
const Dashboard = () => {
// モーダル状態
const [isUserModalOpen, setIsUserModalOpen] =
useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] =
useState(false);
const [isEditModalOpen, setIsEditModalOpen] =
useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] =
useState(false);
// モーダル関連のデータ
const [selectedUser, setSelectedUser] = useState(null);
const [editingItem, setEditingItem] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
// モーダル操作関数
const openUserModal = (user) => {
setSelectedUser(user);
setIsUserModalOpen(true);
};
const openDeleteConfirm = (item) => {
setDeleteTarget(item);
setIsDeleteConfirmOpen(true);
};
const openEditModal = (item) => {
setEditingItem(item);
setIsEditModalOpen(true);
};
// 本来のビジネスロジック
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
// ... 200行以上のコンポーネント
};
本来のビジネスロジックよりも、モーダル管理のコードの方が多くなってしまっています。これでは、コンポーネントの責任が曖昧になり、テストも困難になってしまいますね。
従来の解決手法とその限界
useState + props 渡し
最もシンプルな手法ですが、アプリケーションが成長するにつれて限界が見えてきます。
typescript// 限界1: スケールしない
const ParentComponent = () => {
const [modal1, setModal1] = useState(false);
const [modal2, setModal2] = useState(false);
const [modal3, setModal3] = useState(false);
// モーダルが増えるたびに状態とpropsが増加...
return (
<ChildComponent
onOpenModal1={() => setModal1(true)}
onOpenModal2={() => setModal2(true)}
onOpenModal3={() => setModal3(true)}
// props地獄の始まり...
/>
);
};
限界点:
- モーダル追加のたびに親コンポーネントの修正が必要
- 深いネストでの props 渡しが複雑化
- 関連のないコンポーネントでも props を経由する必要がある
Context API
React の標準機能である Context API での解決も一般的ですが、こちらも課題があります。
typescript// 限界2: Context管理の複雑化
const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error(
'useModalContext must be used within ModalProvider'
);
}
return context;
};
const ModalProvider = ({ children }) => {
const [modals, setModals] = useState({
user: false,
confirm: false,
settings: false,
// 新しいモーダルを追加するたびにここを修正...
});
const openModal = (modalName) => {
setModals((prev) => ({ ...prev, [modalName]: true }));
};
const closeModal = (modalName) => {
setModals((prev) => ({ ...prev, [modalName]: false }));
};
return (
<ModalContext.Provider
value={{ modals, openModal, closeModal }}
>
{children}
</ModalContext.Provider>
);
};
限界点:
- 全てのモーダル状態が一箇所に集中し、関心の分離ができない
- モーダル追加時に Provider の修正が必要
- 不要な再レンダリングが発生しやすい
Redux/Zustand
状態管理ライブラリを使用する手法もありますが、モーダル管理には過剰な場合が多いです。
typescript// 限界3: 過剰な設計
// Redux Toolkit での例
const modalSlice = createSlice({
name: 'modal',
initialState: {
userModal: false,
confirmModal: false,
settingsModal: false,
},
reducers: {
openUserModal: (state) => {
state.userModal = true;
},
closeUserModal: (state) => {
state.userModal = false;
},
// 各モーダルごとにアクションが必要...
},
});
限界点:
- 単純なモーダル開閉のために大量のボイラープレートが必要
- グローバル状態として管理する必要性が低い
- 設定とメンテナンスのコストが高い
Jotai による革新的解決策
atom 単位での状態分離
Jotai の最大の特徴は、状態を atom 単位で分離できることです。モーダルごとに独立した atom を作成することで、関心の分離を実現できます。
typescript// ✨ Jotaiによる解決策
import { atom } from 'jotai';
// 各モーダルの状態を独立したatomで管理
export const userModalAtom = atom(false);
export const confirmModalAtom = atom(false);
export const settingsModalAtom = atom(false);
// モーダル関連のデータも専用atomで管理
export const selectedUserAtom = atom(null);
export const deleteTargetAtom = atom(null);
このアプローチの素晴らしい点は、各モーダルが完全に独立していることです。新しいモーダルを追加する際も、既存のコードに一切影響を与えません。
宣言的な状態管理
Jotai では、状態の操作も非常に宣言的に記述できます。
typescript// モーダル操作のカスタムフック
import { useAtom } from 'jotai';
export const useUserModal = () => {
const [isOpen, setIsOpen] = useAtom(userModalAtom);
const [selectedUser, setSelectedUser] = useAtom(
selectedUserAtom
);
const openModal = (user) => {
setSelectedUser(user);
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
setSelectedUser(null);
};
return {
isOpen,
selectedUser,
openModal,
closeModal,
};
};
このカスタムフックを使用することで、どのコンポーネントからでもモーダルの状態にアクセスできるようになります。
typescript// どのコンポーネントからでも簡単に使用可能
const UserListItem = ({ user }) => {
const { openModal } = useUserModal();
return (
<div onClick={() => openModal(user)}>{user.name}</div>
);
};
const UserModal = () => {
const { isOpen, selectedUser, closeModal } =
useUserModal();
if (!isOpen) return null;
return (
<Modal onClose={closeModal}>
<h2>{selectedUser.name}の詳細</h2>
{/* モーダルの内容 */}
</Modal>
);
};
実装パターン別解決例
基本モーダル
最もシンプルなモーダル管理から始めましょう。
typescript// atoms/modalAtoms.ts
import { atom } from 'jotai';
export const basicModalAtom = atom(false);
// hooks/useBasicModal.ts
import { useAtom } from 'jotai';
import { basicModalAtom } from '../atoms/modalAtoms';
export const useBasicModal = () => {
const [isOpen, setIsOpen] = useAtom(basicModalAtom);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev),
};
};
使用例:
typescript// components/BasicModal.tsx
const BasicModal = () => {
const { isOpen, close } = useBasicModal();
return (
<Modal isOpen={isOpen} onClose={close}>
<h2>基本的なモーダル</h2>
<p>シンプルな情報表示用モーダルです。</p>
</Modal>
);
};
// components/TriggerButton.tsx
const TriggerButton = () => {
const { open } = useBasicModal();
return <button onClick={open}>モーダルを開く</button>;
};
複数モーダル管理
複数のモーダルを同時に管理する場合の実装例です。
typescript// atoms/modalAtoms.ts
import { atom } from 'jotai';
// 各モーダルの状態
export const productModalAtom = atom(false);
export const cartModalAtom = atom(false);
export const checkoutModalAtom = atom(false);
// モーダル関連データ
export const selectedProductAtom = atom(null);
export const cartItemsAtom = atom([]);
// 派生atom:複数モーダルの同時管理
export const anyModalOpenAtom = atom(
(get) =>
get(productModalAtom) ||
get(cartModalAtom) ||
get(checkoutModalAtom)
);
複数モーダル用のカスタムフック:
typescript// hooks/useMultiModal.ts
import { useAtom, useAtomValue } from 'jotai';
import {
productModalAtom,
cartModalAtom,
checkoutModalAtom,
selectedProductAtom,
anyModalOpenAtom,
} from '../atoms/modalAtoms';
export const useProductModal = () => {
const [isOpen, setIsOpen] = useAtom(productModalAtom);
const [selectedProduct, setSelectedProduct] = useAtom(
selectedProductAtom
);
const openModal = (product) => {
setSelectedProduct(product);
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
// 少し遅延してからデータをクリア(アニメーション考慮)
setTimeout(() => setSelectedProduct(null), 300);
};
return { isOpen, selectedProduct, openModal, closeModal };
};
export const useCartModal = () => {
const [isOpen, setIsOpen] = useAtom(cartModalAtom);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
};
};
// 全体的なモーダル制御
export const useModalControl = () => {
const anyModalOpen = useAtomValue(anyModalOpenAtom);
const [, setProductModal] = useAtom(productModalAtom);
const [, setCartModal] = useAtom(cartModalAtom);
const [, setCheckoutModal] = useAtom(checkoutModalAtom);
const closeAllModals = () => {
setProductModal(false);
setCartModal(false);
setCheckoutModal(false);
};
return {
anyModalOpen,
closeAllModals,
};
};
実際の使用例:
typescript// components/ProductCard.tsx
const ProductCard = ({ product }) => {
const { openModal } = useProductModal();
return (
<div className='product-card'>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<button onClick={() => openModal(product)}>
詳細を見る
</button>
</div>
);
};
// components/Header.tsx
const Header = () => {
const { open: openCart } = useCartModal();
const { anyModalOpen, closeAllModals } =
useModalControl();
// ESCキーで全モーダルを閉じる
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && anyModalOpen) {
closeAllModals();
}
};
document.addEventListener('keydown', handleEscape);
return () =>
document.removeEventListener('keydown', handleEscape);
}, [anyModalOpen, closeAllModals]);
return (
<header>
<h1>ECサイト</h1>
<button onClick={openCart}>カート</button>
</header>
);
};
ネストしたダイアログ
モーダルの中から別のモーダルを開く、ネストした構造の実装例です。
typescript// atoms/nestedModalAtoms.ts
import { atom } from 'jotai';
export const mainModalAtom = atom(false);
export const confirmDialogAtom = atom(false);
export const settingsModalAtom = atom(false);
// ネストレベルの管理
export const modalStackAtom = atom([]);
// 現在のモーダル階層を取得
export const currentModalLevelAtom = atom((get) => {
const stack = get(modalStackAtom);
return stack.length;
});
ネストモーダル管理のカスタムフック:
typescript// hooks/useNestedModal.ts
import { useAtom, useAtomValue } from 'jotai';
import {
mainModalAtom,
confirmDialogAtom,
settingsModalAtom,
modalStackAtom,
currentModalLevelAtom,
} from '../atoms/nestedModalAtoms';
export const useNestedModal = () => {
const [modalStack, setModalStack] =
useAtom(modalStackAtom);
const currentLevel = useAtomValue(currentModalLevelAtom);
const pushModal = (modalName) => {
setModalStack((prev) => [...prev, modalName]);
};
const popModal = () => {
setModalStack((prev) => prev.slice(0, -1));
};
const clearAllModals = () => {
setModalStack([]);
};
return {
modalStack,
currentLevel,
pushModal,
popModal,
clearAllModals,
};
};
export const useMainModal = () => {
const [isOpen, setIsOpen] = useAtom(mainModalAtom);
const { pushModal, popModal } = useNestedModal();
const openModal = () => {
setIsOpen(true);
pushModal('main');
};
const closeModal = () => {
setIsOpen(false);
popModal();
};
return { isOpen, openModal, closeModal };
};
export const useConfirmDialog = () => {
const [isOpen, setIsOpen] = useAtom(confirmDialogAtom);
const { pushModal, popModal } = useNestedModal();
const openDialog = () => {
setIsOpen(true);
pushModal('confirm');
};
const closeDialog = () => {
setIsOpen(false);
popModal();
};
return { isOpen, openDialog, closeDialog };
};
ネストモーダルの実装例:
typescript// components/MainModal.tsx
const MainModal = () => {
const { isOpen, closeModal } = useMainModal();
const { openDialog } = useConfirmDialog();
const { currentLevel } = useNestedModal();
return (
<Modal
isOpen={isOpen}
onClose={closeModal}
zIndex={1000 + currentLevel * 10} // 階層に応じてz-indexを調整
>
<h2>メインモーダル</h2>
<p>このモーダルから確認ダイアログを開けます。</p>
<button onClick={openDialog}>削除の確認</button>
<button onClick={closeModal}>閉じる</button>
</Modal>
);
};
// components/ConfirmDialog.tsx
const ConfirmDialog = () => {
const { isOpen, closeDialog } = useConfirmDialog();
const { currentLevel } = useNestedModal();
return (
<Modal
isOpen={isOpen}
onClose={closeDialog}
zIndex={1000 + currentLevel * 10}
overlay={false} // 背景オーバーレイは重複させない
>
<div className='confirm-dialog'>
<h3>削除の確認</h3>
<p>
本当に削除しますか?この操作は取り消せません。
</p>
<div className='button-group'>
<button onClick={closeDialog} className='cancel'>
キャンセル
</button>
<button
onClick={() => {
// 削除処理
handleDelete();
closeDialog();
}}
className='danger'
>
削除する
</button>
</div>
</div>
</Modal>
);
};
パフォーマンス最適化
Jotai を使ったモーダル管理では、いくつかのパフォーマンス最適化のテクニックがあります。
選択的な再レンダリング
typescript// 最適化されたモーダル管理
import { atom } from 'jotai';
// 個別のatomで状態を分離
export const modalVisibilityAtom = atom(false);
export const modalDataAtom = atom(null);
// 派生atomで必要な時のみ計算
export const modalConfigAtom = atom((get) => {
const isVisible = get(modalVisibilityAtom);
const data = get(modalDataAtom);
// モーダルが開いている時のみ設定を計算
if (!isVisible) return null;
return {
title: data?.title || 'デフォルトタイトル',
size: data?.size || 'medium',
animation: data?.animation || 'fadeIn',
};
});
遅延読み込みの実装
typescript// 遅延読み込み対応のモーダル
import { atom } from 'jotai';
import { loadable } from 'jotai/utils';
// 非同期データ取得用のatom
const modalDataAtom = atom(async (get) => {
const modalId = get(currentModalIdAtom);
if (!modalId) return null;
// APIからデータを取得(必要な時のみ)
const response = await fetch(
`/api/modal-data/${modalId}`
);
return response.json();
});
// ローディング状態を含むatom
export const modalDataLoadableAtom =
loadable(modalDataAtom);
// 使用例
const AsyncModal = () => {
const modalData = useAtomValue(modalDataLoadableAtom);
if (modalData.state === 'loading') {
return <LoadingSpinner />;
}
if (modalData.state === 'hasError') {
return <ErrorMessage error={modalData.error} />;
}
return (
<Modal>
<h2>{modalData.data.title}</h2>
<p>{modalData.data.content}</p>
</Modal>
);
};
メモ化による最適化
typescript// React.memoとの組み合わせ
import { memo } from 'react';
import { useAtomValue } from 'jotai';
const ModalContent = memo(({ data }) => {
console.log('ModalContent rendered'); // デバッグ用
return (
<div>
<h2>{data.title}</h2>
<p>{data.description}</p>
</div>
);
});
const OptimizedModal = () => {
const isOpen = useAtomValue(modalVisibilityAtom);
const modalData = useAtomValue(modalDataAtom);
// モーダルが開いていない時は何もレンダリングしない
if (!isOpen) return null;
return (
<Modal>
<ModalContent data={modalData} />
</Modal>
);
};
バッチ更新の活用
typescript// 複数の状態を同時に更新
import { useSetAtom } from 'jotai';
const useModalActions = () => {
const setVisibility = useSetAtom(modalVisibilityAtom);
const setData = useSetAtom(modalDataAtom);
const setLoading = useSetAtom(modalLoadingAtom);
const openModalWithData = useCallback(
(data) => {
// React 18のAutomatic Batchingにより、
// これらの更新は自動的にバッチ化される
setLoading(true);
setData(data);
setVisibility(true);
// 非同期処理完了後
setTimeout(() => {
setLoading(false);
}, 100);
},
[setVisibility, setData, setLoading]
);
return { openModalWithData };
};
まとめ
モーダルやダイアログの開閉状態管理は、React 開発における永続的な課題の一つでした。従来の手法では、props ドリリング、Context 乱立、親コンポーネントの肥大化といった問題に悩まされることが多かったのではないでしょうか。
Jotai を使用することで得られるメリット:
項目 | 従来の手法 | Jotai |
---|---|---|
状態の分離 | 困難(一箇所に集中) | 簡単(atom 単位で分離) |
コード量 | 多い(ボイラープレート) | 少ない(宣言的) |
保守性 | 低い(密結合) | 高い(疎結合) |
パフォーマンス | 不要な再レンダリング | 必要な部分のみ更新 |
テスタビリティ | 困難 | 簡単(atom 単体テスト可能) |
Jotai のアプローチは、**「状態を必要な場所で定義し、必要な場所で使用する」**という自然な考え方に基づいています。これにより、モーダル管理のコードがより直感的で保守しやすくなります。
特に大規模なアプリケーションでは、モーダルの数が増えるにつれて Jotai の恩恵をより強く感じられるでしょう。ぜひ次のプロジェクトで、Jotai を使ったモーダル管理を試してみてください。
きっと、従来の手法では味わえない開発体験の向上を実感していただけるはずです。
関連リンク
- article
モーダルやダイアログの「開閉状態」どこで持つ問題、Jotai ならこう解決する
- article
Jotai で認証状態(Auth Context)を管理するベストプラクティス - ログイン状態の保持からルーティング制御まで
- article
atomWithStorage を使いこなす!Jotai でテーマ(ダークモード)やユーザー設定を永続化する方法
- article
React Hook Formはもう不要?Jotaiで実現する、パフォーマンスを意識したフォーム状態管理術
- article
Zustand と Jotai を比較:軽量ステート管理ライブラリの選び方
- article
Next.js × Jotai で作る SSR 対応のモダン Web アプリケーション