jotai/optics で深いネストを安全に操作する実践ガイド

React アプリケーションの状態管理において、深いネストしたオブジェクトの操作は非常に困難な課題です。従来の useState や useReducer では、Immutable な更新を保ちながら深い階層のプロパティを変更するために、複雑で冗長なコードを書く必要がありました。
jotai/optics は、この問題を解決する画期的なライブラリです。Lenses(レンズ)や Prisms(プリズム)といった関数型プログラミングの概念を React の状態管理に取り入れ、深いネストした状態を安全かつ簡潔に操作できるようになります。
本記事では、jotai/optics を使った実践的な開発手法を、基本から応用まで段階的に解説いたします。
背景
深いネストの課題
現代の Web アプリケーションでは、API から受信するデータや、複雑なフォーム構造により、状態が深くネストすることが頻繁にあります。
typescriptinterface UserProfile {
id: string;
personal: {
name: {
first: string;
last: string;
};
contact: {
email: string;
phone: {
country: string;
number: string;
};
};
};
preferences: {
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
frequency: 'immediate' | 'daily' | 'weekly';
};
};
}
このような構造で、例えば通知設定の email フラグを更新する場合、従来の手法では以下のような冗長なコードが必要でした。
typescript// useState での更新例
const updateEmailNotification = (enabled: boolean) => {
setUserProfile(prevProfile => ({
...prevProfile,
preferences: {
...prevProfile.preferences,
notifications: {
...prevProfile.preferences.notifications,
email: enabled
}
}
}));
};
この方法には以下の問題があります。
# | 課題 | 詳細 |
---|---|---|
1 | 可読性の低下 | スプレッド演算子の多用により、何を更新しているかが分かりにくい |
2 | エラーの温床 | 深いコピーを手動で行うため、参照の誤りが発生しやすい |
3 | 保守性の悪化 | 構造変更時に複数箇所の修正が必要 |
4 | 型安全性の欠如 | TypeScript でも実行時エラーの可能性 |
この図は従来手法における状態更新の複雑さを示しています。
mermaidflowchart TD
A[元の状態] --> B{更新対象の特定}
B --> C[全階層のスプレッド]
C --> D[目的プロパティの更新]
D --> E[新しい状態オブジェクト]
C --> F[エラー発生リスク]
F --> G[参照の誤り]
F --> H[型不整合]
F --> I[不完全なコピー]
図で理解できる要点:
- 状態更新時に全階層でスプレッド演算子が必要
- 手動でのコピーはエラーリスクが高い
- 型安全性の保証が困難
Immutableな状態更新の重要性
React では、状態の Immutability(不変性)を保つことが必須です。これは以下の理由によります。
React の再レンダリング最適化
React は参照の同一性で変更を検知するため、オブジェクトを直接変更(mutation)すると再レンダリングが発生しません。
typescript// ❌ 直接変更(mutation)- 再レンダリングされない
const handleBadUpdate = () => {
userProfile.preferences.theme = 'dark';
setUserProfile(userProfile);
};
// ✅ Immutable な更新 - 再レンダリングされる
const handleGoodUpdate = () => {
setUserProfile(prev => ({
...prev,
preferences: {
...prev.preferences,
theme: 'dark'
}
}));
};
状態の予測可能性
Immutable な更新により、状態の変更履歴を追跡でき、デバッグやテストが容易になります。また、時間旅行デバッグや undo/redo 機能の実装も可能になります。
jotaiの基本概念復習
jotai は atom ベースの状態管理ライブラリです。基本概念を簡潔に振り返りましょう。
atom の定義
typescriptimport { atom } from 'jotai';
// プリミティブな atom
const countAtom = atom(0);
// 読み取り専用 atom
const doubleCountAtom = atom(get => get(countAtom) * 2);
// 読み書き可能な atom
const incrementAtom = atom(
get => get(countAtom),
(get, set) => set(countAtom, get(countAtom) + 1)
);
atom の使用
typescriptimport { useAtom, useAtomValue, useSetAtom } from 'jotai';
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubleCount = useAtomValue(doubleCountAtom);
const increment = useSetAtom(incrementAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
jotai/opticsとは
optics概念の理解
Optics は関数型プログラミングの概念で、データ構造の特定の部分にフォーカスして、読み取りや更新を行うためのツールです。
Lens(レンズ)
Lens は、データ構造の特定のプロパティに「焦点」を合わせる仕組みです。まるで虫眼鏡のように、大きなオブジェクトの一部分を拡大して操作できます。
typescript// 概念的な Lens の例
const nameLens = lens(
// getter: データから値を取得
(user: User) => user.profile.name,
// setter: データに値を設定
(user: User, newName: string) => ({
...user,
profile: { ...user.profile, name: newName }
})
);
Prism(プリズム)
Prism は、データ構造の特定の「形」にマッチする部分にフォーカスします。条件に合致する場合のみ操作が実行されます。
typescript// Optional な値を扱う Prism の例
const emailPrism = prism(
// matcher: 条件をチェック
(contact: Contact) => contact.email ? some(contact.email) : none(),
// updater: 条件に合致する場合のみ更新
(contact: Contact, newEmail: string) => ({ ...contact, email: newEmail })
);
この図は Optics による構造化データアクセスの仕組みを示しています。
mermaidflowchart LR
A[元データ構造] --> B[Lens/Prism]
B --> C[フォーカス対象]
C --> D[読み取り/更新]
D --> E[新データ構造]
B --> F[型安全保証]
B --> G[Immutable操作]
B --> H[合成可能]
図で理解できる要点:
- Optics が複雑な構造への安全なアクセスを提供
- 型安全性と Immutability が自動的に保証される
- 複数の Optics を合成して深いアクセスが可能
jotaiにおけるoptics実装
jotai/optics は、この Optics の概念を jotai の atom システムに統合したライブラリです。
パッケージのインストール
bashyarn add jotai
yarn add jotai-optics
基本的な使い方
typescriptimport { atom } from 'jotai';
import { focusAtom } from 'jotai-optics';
// ベースとなる atom
const userAtom = atom<UserProfile>({
id: '1',
personal: {
name: { first: 'John', last: 'Doe' },
contact: {
email: 'john@example.com',
phone: { country: '+1', number: '555-1234' }
}
},
preferences: {
theme: 'light',
notifications: {
email: true,
push: false,
frequency: 'daily'
}
}
});
// 特定のプロパティにフォーカスした atom
const firstNameAtom = focusAtom(userAtom, optic =>
optic.prop('personal').prop('name').prop('first')
);
focusAtom の仕組み
focusAtom は、既存の atom に対して「レンズ」を適用し、新しい atom を作成します。この新しい atom は、元の atom の一部分のみを扱います。
typescript// firstNameAtom を使用すると、自動的に深いネストが処理される
function UserNameEditor() {
const [firstName, setFirstName] = useAtom(firstNameAtom);
// この更新は自動的に元の userAtom を Immutable に更新する
const handleUpdate = (newName: string) => {
setFirstName(newName);
};
return (
<input
value={firstName}
onChange={e => handleUpdate(e.target.value)}
/>
);
}
従来の手法との比較
jotai/optics を導入することで、コードの質が劇的に改善されます。
コード量の比較
更新対象 | 従来手法(行数) | jotai/optics(行数) | 削減率 |
---|---|---|---|
単一プロパティ | 8-12行 | 2-3行 | 70-75% |
深いネスト | 15-25行 | 3-5行 | 80-85% |
配列要素 | 10-20行 | 4-6行 | 70-80% |
型安全性の比較
typescript// 従来手法:実行時エラーの可能性
const updateTheme = (theme: string) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
theme: theme as 'light' | 'dark' // 型アサーションが必要
}
}));
};
// jotai/optics:コンパイル時に型チェック
const themeAtom = focusAtom(userAtom, optic =>
optic.prop('preferences').prop('theme')
);
const updateTheme = useSetAtom(themeAtom);
// updateTheme('invalid'); // TypeScript エラー
updateTheme('dark'); // ✅ 正常
エラー処理の比較
typescript// 従来手法:null チェックが複雑
const updateEmail = (email: string) => {
setUser(prev => {
if (!prev.personal?.contact) return prev;
return {
...prev,
personal: {
...prev.personal,
contact: {
...prev.personal.contact,
email
}
}
};
});
};
// jotai/optics:自動的に安全な更新
const emailAtom = focusAtom(userAtom, optic =>
optic.prop('personal').prop('contact').prop('email')
);
基本的なoptics操作
focusAtomの基本使用法
focusAtom は jotai/optics の中核となる関数です。既存の atom に対してレンズを適用し、特定の部分にフォーカスした新しい atom を作成します。
基本的な構文
typescriptimport { focusAtom } from 'jotai-optics';
const focusedAtom = focusAtom(baseAtom, optic =>
optic.prop('property1').prop('property2')
);
プロパティアクセス
最も基本的な操作は、オブジェクトのプロパティにアクセスすることです。
typescript// ユーザー情報の atom
const userAtom = atom({
id: 1,
name: 'Alice',
profile: {
age: 30,
location: 'Tokyo'
}
});
// 名前にフォーカス
const nameAtom = focusAtom(userAtom, optic => optic.prop('name'));
// 年齢にフォーカス
const ageAtom = focusAtom(userAtom, optic =>
optic.prop('profile').prop('age')
);
型推論の活用
TypeScript を使用している場合、focusAtom は完全な型推論を提供します。
typescriptinterface Product {
id: string;
details: {
name: string;
price: number;
specifications: {
weight: number;
dimensions: {
width: number;
height: number;
depth: number;
};
};
};
}
const productAtom = atom<Product>({
id: 'p1',
details: {
name: 'Laptop',
price: 1200,
specifications: {
weight: 2.5,
dimensions: { width: 30, height: 20, depth: 2 }
}
}
});
// 型推論により、widthAtom は number 型として推論される
const widthAtom = focusAtom(productAtom, optic =>
optic.prop('details')
.prop('specifications')
.prop('dimensions')
.prop('width')
);
単一プロパティへのアクセス
単一プロパティへのアクセスは、最もシンプルな optics 操作です。
読み取り操作
typescriptfunction ProductInfo() {
const productName = useAtomValue(
focusAtom(productAtom, optic => optic.prop('details').prop('name'))
);
const price = useAtomValue(
focusAtom(productAtom, optic => optic.prop('details').prop('price'))
);
return (
<div>
<h2>{productName}</h2>
<p>価格: ¥{price.toLocaleString()}</p>
</div>
);
}
更新操作
typescriptfunction PriceEditor() {
const priceAtom = focusAtom(productAtom, optic =>
optic.prop('details').prop('price')
);
const [price, setPrice] = useAtom(priceAtom);
const handlePriceChange = (newPrice: number) => {
// この操作は自動的に product 全体を Immutable に更新
setPrice(newPrice);
};
return (
<input
type="number"
value={price}
onChange={e => handlePriceChange(Number(e.target.value))}
/>
);
}
基本的な更新操作
jotai/optics による更新操作は、常に Immutable な方式で実行されます。
直接更新
typescript// 設定情報の atom
const settingsAtom = atom({
ui: {
theme: 'light' as 'light' | 'dark',
language: 'ja' as 'ja' | 'en',
fontSize: 14
},
privacy: {
analyticsEnabled: true,
crashReporting: false
}
});
// テーマ設定の更新
function ThemeToggle() {
const themeAtom = focusAtom(settingsAtom, optic =>
optic.prop('ui').prop('theme')
);
const [theme, setTheme] = useAtom(themeAtom);
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<button onClick={toggleTheme}>
テーマ: {theme === 'light' ? 'ライト' : 'ダーク'}
</button>
);
}
条件付き更新
typescript// フォントサイズの調整
function FontSizeController() {
const fontSizeAtom = focusAtom(settingsAtom, optic =>
optic.prop('ui').prop('fontSize')
);
const [fontSize, setFontSize] = useAtom(fontSizeAtom);
const increaseFontSize = () => {
if (fontSize < 24) {
setFontSize(fontSize + 2);
}
};
const decreaseFontSize = () => {
if (fontSize > 10) {
setFontSize(fontSize - 2);
}
};
return (
<div>
<p style={{ fontSize }}>フォントサイズ: {fontSize}px</p>
<button onClick={decreaseFontSize}>小さく</button>
<button onClick={increaseFontSize}>大きく</button>
</div>
);
}
複数プロパティの連携更新
typescript// 言語変更時にフォントサイズも調整
function LanguageSelector() {
const languageAtom = focusAtom(settingsAtom, optic =>
optic.prop('ui').prop('language')
);
const fontSizeAtom = focusAtom(settingsAtom, optic =>
optic.prop('ui').prop('fontSize')
);
const [language, setLanguage] = useAtom(languageAtom);
const setFontSize = useSetAtom(fontSizeAtom);
const handleLanguageChange = (newLanguage: 'ja' | 'en') => {
setLanguage(newLanguage);
// 日本語の場合はフォントサイズを少し大きく
const recommendedSize = newLanguage === 'ja' ? 16 : 14;
setFontSize(recommendedSize);
};
return (
<select
value={language}
onChange={e => handleLanguageChange(e.target.value as 'ja' | 'en')}
>
<option value="ja">日本語</option>
<option value="en">English</option>
</select>
);
}
基本的な optics 操作により、複雑な状態構造でも直感的で安全な操作が可能になります。次のセクションでは、より深いネストや複雑な構造への対応方法を詳しく解説いたします。
深いネストの安全な操作
多層ネストへのアクセス
実際のアプリケーションでは、5 層以上の深いネスト構造が頻繁に発生します。jotai/optics は、このような構造でも型安全性を保ちながら簡潔に操作できます。
企業組織データの例
typescriptinterface Organization {
id: string;
company: {
name: string;
departments: {
engineering: {
teams: {
frontend: {
members: Array<{
id: string;
name: string;
role: string;
skills: {
languages: string[];
frameworks: string[];
experience: {
years: number;
projects: Array<{
name: string;
duration: number;
technologies: string[];
}>;
};
};
}>;
projects: Array<{
name: string;
status: 'planning' | 'development' | 'completed';
assignedMembers: string[];
}>;
};
};
};
};
};
}
この複雑な構造から、特定のメンバーの経験年数にアクセスする場合を見てみましょう。
typescriptconst organizationAtom = atom<Organization>({
id: 'org1',
company: {
name: 'TechCorp',
departments: {
engineering: {
teams: {
frontend: {
members: [
{
id: 'emp1',
name: '田中太郎',
role: 'Senior Developer',
skills: {
languages: ['TypeScript', 'JavaScript'],
frameworks: ['React', 'Next.js'],
experience: {
years: 5,
projects: [
{
name: 'ECサイト構築',
duration: 6,
technologies: ['React', 'Node.js']
}
]
}
}
}
],
projects: []
}
}
}
}
}
});
// 最初のメンバーの経験年数にフォーカス
const firstMemberExperienceAtom = focusAtom(organizationAtom, optic =>
optic.prop('company')
.prop('departments')
.prop('engineering')
.prop('teams')
.prop('frontend')
.prop('members')
.at(0) // 配列の最初の要素
.prop('skills')
.prop('experience')
.prop('years')
);
安全なアクセスパターン
深いネストでは、途中の階層が undefined や null の可能性があります。jotai/optics は optional chaining の概念を提供します。
typescript// オプショナルなプロパティを含む構造
interface ApiResponse {
data?: {
user?: {
profile?: {
settings?: {
notifications?: {
email?: boolean;
};
};
};
};
};
}
const apiResponseAtom = atom<ApiResponse>({});
// optional() を使用して安全にアクセス
const emailNotificationAtom = focusAtom(apiResponseAtom, optic =>
optic.optional()
.prop('data')
.optional()
.prop('user')
.optional()
.prop('profile')
.optional()
.prop('settings')
.optional()
.prop('notifications')
.optional()
.prop('email')
);
この図は深いネストアクセスの安全性確保の仕組みを示しています。
mermaidflowchart TD
A[ベースAtom] --> B[第1層アクセス]
B --> C{undefined チェック}
C -->|OK| D[第2層アクセス]
C -->|NG| E[undefined 返却]
D --> F{undefined チェック}
F -->|OK| G[第3層以降...]
F -->|NG| E
G --> H[目標プロパティ]
H --> I[型安全な値]
図で理解できる要点:
- 各階層で自動的に undefined チェックが実行される
- エラーが発生せず、安全に undefined が返される
- 型推論により最終的な型が正確に決定される
配列とオブジェクトの混合操作
実際のアプリケーションでは、配列とオブジェクトが混在した複雑な構造が一般的です。
ショッピングカートの例
typescriptinterface ShoppingCart {
id: string;
items: Array<{
productId: string;
product: {
name: string;
price: number;
variants: Array<{
id: string;
name: string;
attributes: {
color: string;
size: string;
stock: number;
};
}>;
};
selectedVariant: {
variantId: string;
quantity: number;
};
}>;
summary: {
subtotal: number;
tax: number;
shipping: number;
total: number;
};
}
const cartAtom = atom<ShoppingCart>({
id: 'cart1',
items: [
{
productId: 'p1',
product: {
name: 'Tシャツ',
price: 2980,
variants: [
{
id: 'v1',
name: 'Mサイズ・赤',
attributes: { color: '赤', size: 'M', stock: 10 }
},
{
id: 'v2',
name: 'Lサイズ・青',
attributes: { color: '青', size: 'L', stock: 5 }
}
]
},
selectedVariant: { variantId: 'v1', quantity: 2 }
}
],
summary: { subtotal: 5960, tax: 596, shipping: 500, total: 7056 }
});
特定アイテムの数量変更
typescript// インデックスを指定してアイテムの数量にアクセス
const itemQuantityAtom = (itemIndex: number) =>
focusAtom(cartAtom, optic =>
optic.prop('items')
.at(itemIndex)
.prop('selectedVariant')
.prop('quantity')
);
function CartItemQuantity({ itemIndex }: { itemIndex: number }) {
const quantityAtom = itemQuantityAtom(itemIndex);
const [quantity, setQuantity] = useAtom(quantityAtom);
const updateQuantity = (newQuantity: number) => {
if (newQuantity >= 1 && newQuantity <= 99) {
setQuantity(newQuantity);
}
};
return (
<div>
<button onClick={() => updateQuantity(quantity - 1)}>-</button>
<span>{quantity}</span>
<button onClick={() => updateQuantity(quantity + 1)}>+</button>
</div>
);
}
商品バリアントの在庫確認
typescript// 特定の商品の特定のバリアントの在庫にアクセス
const variantStockAtom = (itemIndex: number, variantIndex: number) =>
focusAtom(cartAtom, optic =>
optic.prop('items')
.at(itemIndex)
.prop('product')
.prop('variants')
.at(variantIndex)
.prop('attributes')
.prop('stock')
);
function VariantStockChecker({
itemIndex,
variantIndex
}: {
itemIndex: number;
variantIndex: number;
}) {
const stock = useAtomValue(variantStockAtom(itemIndex, variantIndex));
return (
<span className={stock > 0 ? 'in-stock' : 'out-of-stock'}>
在庫: {stock > 0 ? `${stock}個` : '売り切れ'}
</span>
);
}
配列操作のヘルパー関数
typescript// 新しいアイテムの追加
const addCartItem = (item: ShoppingCart['items'][0]) => {
const currentItems = useAtomValue(
focusAtom(cartAtom, optic => optic.prop('items'))
);
const setItems = useSetAtom(
focusAtom(cartAtom, optic => optic.prop('items'))
);
setItems([...currentItems, item]);
};
// アイテムの削除
const removeCartItem = (itemIndex: number) => {
const currentItems = useAtomValue(
focusAtom(cartAtom, optic => optic.prop('items'))
);
const setItems = useSetAtom(
focusAtom(cartAtom, optic => optic.prop('items'))
);
setItems(currentItems.filter((_, index) => index !== itemIndex));
};
エラー回避のためのベストプラクティス
深いネスト操作では、様々なエラーが発生する可能性があります。以下のベストプラクティスに従うことで、安全性を確保できます。
1. 型ガードの活用
typescript// 型ガード関数の定義
const isValidCartItem = (item: any): item is ShoppingCart['items'][0] => {
return item &&
typeof item.productId === 'string' &&
item.product &&
Array.isArray(item.product.variants) &&
item.selectedVariant &&
typeof item.selectedVariant.quantity === 'number';
};
// 安全なアクセス
function SafeCartItemDisplay({ itemIndex }: { itemIndex: number }) {
const items = useAtomValue(
focusAtom(cartAtom, optic => optic.prop('items'))
);
const item = items[itemIndex];
if (!isValidCartItem(item)) {
return <div>無効なアイテムです</div>;
}
// この時点で item は型安全
return <div>{item.product.name}</div>;
}
2. デフォルト値の設定
typescript// デフォルト値を持つ atom
const userPreferencesAtom = atom({
theme: 'light' as 'light' | 'dark',
language: 'ja' as 'ja' | 'en',
notifications: {
email: true,
push: false,
frequency: 'daily' as 'immediate' | 'daily' | 'weekly'
}
});
// デフォルト値を使用するヘルパー
const getNotificationSetting = (
preferences: typeof userPreferencesAtom extends atom<infer T> ? T : never,
key: keyof typeof preferences.notifications
) => {
return preferences.notifications?.[key] ?? false;
};
3. 境界値のチェック
typescript// 配列アクセス時の境界チェック
const safeArrayAccessAtom = <T>(
baseAtom: Atom<T[]>,
index: number
) => {
return atom(
get => {
const array = get(baseAtom);
return index >= 0 && index < array.length ? array[index] : undefined;
},
(get, set, newValue: T) => {
const array = get(baseAtom);
if (index >= 0 && index < array.length) {
const newArray = [...array];
newArray[index] = newValue;
set(baseAtom, newArray);
}
}
);
};
4. エラーログの実装
typescript// エラー追跡のためのラッパー
const createSafeAtom = <T>(
baseAtom: Atom<T>,
path: string[]
) => {
return atom(
get => {
try {
return get(baseAtom);
} catch (error) {
console.error(`アクセスエラー (${path.join('.')}):`, error);
return undefined;
}
},
(get, set, newValue: T) => {
try {
set(baseAtom, newValue);
} catch (error) {
console.error(`更新エラー (${path.join('.')}):`, error);
}
}
);
};
これらのベストプラクティスを適用することで、深いネスト構造でも安全で保守性の高いコードを実現できます。
実践的な使用例
ユーザープロファイル管理システム
実際のユーザープロファイル管理システムを例に、jotai/optics の実践的な活用方法を詳しく解説します。
データ構造の設計
typescriptinterface UserProfile {
id: string;
personal: {
basicInfo: {
firstName: string;
lastName: string;
displayName: string;
dateOfBirth: string;
};
contact: {
email: string;
phone: {
countryCode: string;
number: string;
verified: boolean;
};
address: {
street: string;
city: string;
prefecture: string;
postalCode: string;
country: string;
};
};
};
professional: {
title: string;
company: string;
department: string;
skills: Array<{
category: string;
items: Array<{
name: string;
level: 1 | 2 | 3 | 4 | 5;
verified: boolean;
}>;
}>;
experience: Array<{
company: string;
position: string;
startDate: string;
endDate?: string;
description: string;
}>;
};
preferences: {
privacy: {
profileVisibility: 'public' | 'private' | 'contacts';
showEmail: boolean;
showPhone: boolean;
};
notifications: {
email: boolean;
push: boolean;
frequency: 'immediate' | 'daily' | 'weekly';
};
};
}
ベース atom の定義
typescriptconst userProfileAtom = atom<UserProfile>({
id: 'user123',
personal: {
basicInfo: {
firstName: '太郎',
lastName: '田中',
displayName: '田中太郎',
dateOfBirth: '1990-01-01'
},
contact: {
email: 'taro@example.com',
phone: {
countryCode: '+81',
number: '90-1234-5678',
verified: false
},
address: {
street: '1-1-1 渋谷',
city: '渋谷区',
prefecture: '東京都',
postalCode: '150-0002',
country: '日本'
}
}
},
professional: {
title: 'フロントエンドエンジニア',
company: 'テック株式会社',
department: '開発部',
skills: [
{
category: 'フロントエンド',
items: [
{ name: 'React', level: 4, verified: true },
{ name: 'TypeScript', level: 3, verified: true }
]
}
],
experience: []
},
preferences: {
privacy: {
profileVisibility: 'public',
showEmail: true,
showPhone: false
},
notifications: {
email: true,
push: false,
frequency: 'daily'
}
}
});
基本情報編集コンポーネント
typescript// 各フィールド用の atom を作成
const firstNameAtom = focusAtom(userProfileAtom, optic =>
optic.prop('personal').prop('basicInfo').prop('firstName')
);
const lastNameAtom = focusAtom(userProfileAtom, optic =>
optic.prop('personal').prop('basicInfo').prop('lastName')
);
const emailAtom = focusAtom(userProfileAtom, optic =>
optic.prop('personal').prop('contact').prop('email')
);
基本情報の編集フォームでは、各フィールドが独立して管理されます。
typescriptfunction BasicInfoEditor() {
const [firstName, setFirstName] = useAtom(firstNameAtom);
const [lastName, setLastName] = useAtom(lastNameAtom);
const [email, setEmail] = useAtom(emailAtom);
// 表示名を自動更新する関数
const updateDisplayName = () => {
const displayNameAtom = focusAtom(userProfileAtom, optic =>
optic.prop('personal').prop('basicInfo').prop('displayName')
);
const setDisplayName = useSetAtom(displayNameAtom);
setDisplayName(`${lastName} ${firstName}`);
};
return (
<form>
<div>
<label>姓</label>
<input
value={lastName}
onChange={e => {
setLastName(e.target.value);
updateDisplayName();
}}
/>
</div>
<div>
<label>名</label>
<input
value={firstName}
onChange={e => {
setFirstName(e.target.value);
updateDisplayName();
}}
/>
</div>
<div>
<label>メールアドレス</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</div>
</form>
);
}
住所情報の管理
住所情報は複数のフィールドが連携する複雑な例です。
typescript// 住所の各コンポーネント用 atom
const addressAtom = focusAtom(userProfileAtom, optic =>
optic.prop('personal').prop('contact').prop('address')
);
const postalCodeAtom = focusAtom(addressAtom, optic =>
optic.prop('postalCode')
);
const prefectureAtom = focusAtom(addressAtom, optic =>
optic.prop('prefecture')
);
const cityAtom = focusAtom(addressAtom, optic =>
optic.prop('city')
);
const streetAtom = focusAtom(addressAtom, optic =>
optic.prop('street')
);
郵便番号から住所を自動入力する機能を実装できます。
typescriptfunction AddressEditor() {
const [postalCode, setPostalCode] = useAtom(postalCodeAtom);
const [prefecture, setPrefecture] = useAtom(prefectureAtom);
const [city, setCity] = useAtom(cityAtom);
const [street, setStreet] = useAtom(streetAtom);
// 郵便番号から住所を検索
const handlePostalCodeChange = async (code: string) => {
setPostalCode(code);
if (code.length === 7) {
try {
const response = await fetch(`/api/postal/${code}`);
const addressData = await response.json();
setPrefecture(addressData.prefecture);
setCity(addressData.city);
} catch (error) {
console.error('住所検索エラー:', error);
}
}
};
return (
<div>
<div>
<label>郵便番号</label>
<input
value={postalCode}
onChange={e => handlePostalCodeChange(e.target.value)}
placeholder="1234567"
/>
</div>
<div>
<label>都道府県</label>
<input value={prefecture} onChange={e => setPrefecture(e.target.value)} />
</div>
<div>
<label>市区町村</label>
<input value={city} onChange={e => setCity(e.target.value)} />
</div>
<div>
<label>丁目・番地</label>
<input value={street} onChange={e => setStreet(e.target.value)} />
</div>
</div>
);
}
スキル管理の実装
配列操作が必要な専門スキルの管理例です。
typescript// スキルカテゴリ配列にアクセス
const skillCategoriesAtom = focusAtom(userProfileAtom, optic =>
optic.prop('professional').prop('skills')
);
// 特定カテゴリのスキル一覧
const categorySkillsAtom = (categoryIndex: number) =>
focusAtom(skillCategoriesAtom, optic =>
optic.at(categoryIndex).prop('items')
);
スキル管理コンポーネントでは、動的な配列操作が必要です。
typescriptfunction SkillManager() {
const [skillCategories, setSkillCategories] = useAtom(skillCategoriesAtom);
const addSkillCategory = (categoryName: string) => {
setSkillCategories([
...skillCategories,
{ category: categoryName, items: [] }
]);
};
const addSkillToCategory = (categoryIndex: number, skill: {
name: string;
level: 1 | 2 | 3 | 4 | 5;
verified: boolean;
}) => {
const categorySkills = skillCategories[categoryIndex].items;
const newSkills = [...categorySkills, skill];
const newCategories = [...skillCategories];
newCategories[categoryIndex] = {
...newCategories[categoryIndex],
items: newSkills
};
setSkillCategories(newCategories);
};
return (
<div>
{skillCategories.map((category, categoryIndex) => (
<SkillCategory
key={categoryIndex}
category={category}
onAddSkill={skill => addSkillToCategory(categoryIndex, skill)}
/>
))}
<button onClick={() => addSkillCategory('新しいカテゴリ')}>
カテゴリを追加
</button>
</div>
);
}
この図はユーザープロファイル管理システムの構造を示しています。
mermaidflowchart TD
A[UserProfile Atom] --> B[Personal Info]
A --> C[Professional Info]
A --> D[Preferences]
B --> E[Basic Info]
B --> F[Contact Info]
E --> G[Name Editor]
F --> H[Address Editor]
F --> I[Phone Editor]
C --> J[Skills Manager]
C --> K[Experience Editor]
D --> L[Privacy Settings]
D --> M[Notification Settings]
図で理解できる要点:
- 各セクションが独立したコンポーネントで管理される
- optics により深いネストも型安全にアクセス可能
- 部分的な更新が自動的に全体に反映される
フォームデータの操作
複雑なマルチステップフォームでの jotai/optics 活用例を紹介します。
フォーム構造の定義
typescriptinterface RegistrationForm {
step: number;
steps: {
personal: {
completed: boolean;
data: {
name: { first: string; last: string; };
email: string;
phone: string;
birthDate: string;
};
validation: {
errors: Record<string, string>;
touched: Record<string, boolean>;
};
};
professional: {
completed: boolean;
data: {
company: string;
position: string;
experience: number;
skills: string[];
};
validation: {
errors: Record<string, string>;
touched: Record<string, boolean>;
};
};
preferences: {
completed: boolean;
data: {
newsletter: boolean;
notifications: boolean;
privacy: 'public' | 'private';
};
validation: {
errors: Record<string, string>;
touched: Record<string, boolean>;
};
};
};
}
フォーム atom とバリデーション
typescriptconst registrationFormAtom = atom<RegistrationForm>({
step: 1,
steps: {
personal: {
completed: false,
data: {
name: { first: '', last: '' },
email: '',
phone: '',
birthDate: ''
},
validation: { errors: {}, touched: {} }
},
professional: {
completed: false,
data: {
company: '',
position: '',
experience: 0,
skills: []
},
validation: { errors: {}, touched: {} }
},
preferences: {
completed: false,
data: {
newsletter: false,
notifications: true,
privacy: 'private'
},
validation: { errors: {}, touched: {} }
}
}
});
// 現在のステップ
const currentStepAtom = focusAtom(registrationFormAtom, optic =>
optic.prop('step')
);
// 個人情報ステップのデータ
const personalDataAtom = focusAtom(registrationFormAtom, optic =>
optic.prop('steps').prop('personal').prop('data')
);
// 個人情報ステップのバリデーション
const personalValidationAtom = focusAtom(registrationFormAtom, optic =>
optic.prop('steps').prop('personal').prop('validation')
);
フィールドレベルのバリデーション
typescript// カスタムフック: フィールドのバリデーション付き管理
function useValidatedField<T>(
dataAtom: Atom<T>,
validationAtom: Atom<{ errors: Record<string, string>; touched: Record<string, boolean> }>,
fieldName: string,
validator: (value: T) => string | null
) {
const [value, setValue] = useAtom(dataAtom);
const [validation, setValidation] = useAtom(validationAtom);
const updateValue = (newValue: T) => {
setValue(newValue);
// フィールドをタッチ済みにマーク
setValidation(prev => ({
...prev,
touched: { ...prev.touched, [fieldName]: true }
}));
// バリデーション実行
const error = validator(newValue);
setValidation(prev => ({
...prev,
errors: error
? { ...prev.errors, [fieldName]: error }
: { ...prev.errors, [fieldName]: undefined }
}));
};
const error = validation.errors[fieldName];
const touched = validation.touched[fieldName];
return { value, updateValue, error, touched, hasError: touched && !!error };
}
個人情報ステップコンポーネント
typescriptfunction PersonalInfoStep() {
// 各フィールドの atom を作成
const emailAtom = focusAtom(personalDataAtom, optic => optic.prop('email'));
const firstNameAtom = focusAtom(personalDataAtom, optic =>
optic.prop('name').prop('first')
);
const lastNameAtom = focusAtom(personalDataAtom, optic =>
optic.prop('name').prop('last')
);
// バリデーション付きフィールド
const email = useValidatedField(
emailAtom,
personalValidationAtom,
'email',
(value: string) => {
if (!value) return 'メールアドレスは必須です';
if (!/\S+@\S+\.\S+/.test(value)) return '有効なメールアドレスを入力してください';
return null;
}
);
const firstName = useValidatedField(
firstNameAtom,
personalValidationAtom,
'firstName',
(value: string) => value ? null : '名前は必須です'
);
const lastName = useValidatedField(
lastNameAtom,
personalValidationAtom,
'lastName',
(value: string) => value ? null : '姓は必須です'
);
// ステップ完了チェック
const completeStep = () => {
const hasErrors = [email, firstName, lastName].some(field => field.hasError);
const allTouched = [email, firstName, lastName].every(field => field.touched);
if (!hasErrors && allTouched) {
const setCompleted = useSetAtom(focusAtom(registrationFormAtom, optic =>
optic.prop('steps').prop('personal').prop('completed')
));
setCompleted(true);
}
};
return (
<div>
<h2>個人情報</h2>
<div>
<label>姓</label>
<input
value={lastName.value}
onChange={e => lastName.updateValue(e.target.value)}
className={lastName.hasError ? 'error' : ''}
/>
{lastName.hasError && <span className="error">{lastName.error}</span>}
</div>
<div>
<label>名</label>
<input
value={firstName.value}
onChange={e => firstName.updateValue(e.target.value)}
className={firstName.hasError ? 'error' : ''}
/>
{firstName.hasError && <span className="error">{firstName.error}</span>}
</div>
<div>
<label>メールアドレス</label>
<input
type="email"
value={email.value}
onChange={e => email.updateValue(e.target.value)}
className={email.hasError ? 'error' : ''}
/>
{email.hasError && <span className="error">{email.error}</span>}
</div>
<button onClick={completeStep}>次へ</button>
</div>
);
}
設定値の深い階層管理
アプリケーション設定の管理は、深い階層構造と複雑な相互依存関係を持つ典型例です。
アプリケーション設定の構造
typescriptinterface AppSettings {
appearance: {
theme: {
mode: 'light' | 'dark' | 'auto';
customColors: {
primary: string;
secondary: string;
accent: string;
};
typography: {
fontFamily: string;
fontSize: {
base: number;
scale: number;
};
lineHeight: number;
};
};
layout: {
sidebar: {
position: 'left' | 'right';
width: number;
collapsible: boolean;
defaultCollapsed: boolean;
};
header: {
height: number;
showLogo: boolean;
showSearch: boolean;
};
};
};
behavior: {
animations: {
enabled: boolean;
duration: number;
easing: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
};
accessibility: {
highContrast: boolean;
reducedMotion: boolean;
screenReader: boolean;
};
performance: {
lazyLoading: boolean;
caching: {
enabled: boolean;
duration: number;
strategy: 'memory' | 'localStorage' | 'sessionStorage';
};
};
};
advanced: {
developer: {
debugMode: boolean;
showPerformanceMetrics: boolean;
logLevel: 'error' | 'warn' | 'info' | 'debug';
};
experimental: {
features: Array<{
id: string;
name: string;
enabled: boolean;
beta: boolean;
}>;
};
};
}
設定管理の実装
typescriptconst appSettingsAtom = atom<AppSettings>({
appearance: {
theme: {
mode: 'light',
customColors: {
primary: '#3b82f6',
secondary: '#64748b',
accent: '#f59e0b'
},
typography: {
fontFamily: 'Inter',
fontSize: { base: 16, scale: 1.2 },
lineHeight: 1.6
}
},
layout: {
sidebar: {
position: 'left',
width: 280,
collapsible: true,
defaultCollapsed: false
},
header: {
height: 64,
showLogo: true,
showSearch: true
}
}
},
behavior: {
animations: {
enabled: true,
duration: 300,
easing: 'ease-out'
},
accessibility: {
highContrast: false,
reducedMotion: false,
screenReader: false
},
performance: {
lazyLoading: true,
caching: {
enabled: true,
duration: 3600000,
strategy: 'memory'
}
}
},
advanced: {
developer: {
debugMode: false,
showPerformanceMetrics: false,
logLevel: 'warn'
},
experimental: {
features: [
{ id: 'new-ui', name: '新しいUI', enabled: false, beta: true }
]
}
}
});
テーマ設定コンポーネント
typescript// テーマ関連の atom
const themeModeAtom = focusAtom(appSettingsAtom, optic =>
optic.prop('appearance').prop('theme').prop('mode')
);
const customColorsAtom = focusAtom(appSettingsAtom, optic =>
optic.prop('appearance').prop('theme').prop('customColors')
);
const fontSettingsAtom = focusAtom(appSettingsAtom, optic =>
optic.prop('appearance').prop('theme').prop('typography')
);
function ThemeSettings() {
const [themeMode, setThemeMode] = useAtom(themeModeAtom);
const [customColors, setCustomColors] = useAtom(customColorsAtom);
const [fontSettings, setFontSettings] = useAtom(fontSettingsAtom);
// 色設定の更新
const updateColor = (colorType: keyof typeof customColors, color: string) => {
setCustomColors(prev => ({ ...prev, [colorType]: color }));
};
// フォントサイズの計算された値
const calculatedFontSizes = {
small: Math.round(fontSettings.fontSize.base / fontSettings.fontSize.scale),
base: fontSettings.fontSize.base,
large: Math.round(fontSettings.fontSize.base * fontSettings.fontSize.scale),
xlarge: Math.round(fontSettings.fontSize.base * Math.pow(fontSettings.fontSize.scale, 2))
};
return (
<div>
<h3>テーマ設定</h3>
<div>
<label>テーマモード</label>
<select
value={themeMode}
onChange={e => setThemeMode(e.target.value as typeof themeMode)}
>
<option value="light">ライト</option>
<option value="dark">ダーク</option>
<option value="auto">自動</option>
</select>
</div>
<div>
<h4>カスタムカラー</h4>
{Object.entries(customColors).map(([key, value]) => (
<div key={key}>
<label>{key}</label>
<input
type="color"
value={value}
onChange={e => updateColor(key as keyof typeof customColors, e.target.value)}
/>
</div>
))}
</div>
<div>
<h4>フォント設定</h4>
<div>
<label>ベースフォントサイズ: {fontSettings.fontSize.base}px</label>
<input
type="range"
min="12"
max="24"
value={fontSettings.fontSize.base}
onChange={e => setFontSettings(prev => ({
...prev,
fontSize: { ...prev.fontSize, base: Number(e.target.value) }
}))}
/>
</div>
<div>
<label>スケール比: {fontSettings.fontSize.scale}</label>
<input
type="range"
min="1.1"
max="1.5"
step="0.1"
value={fontSettings.fontSize.scale}
onChange={e => setFontSettings(prev => ({
...prev,
fontSize: { ...prev.fontSize, scale: Number(e.target.value) }
}))}
/>
</div>
<div>
<h5>計算されたフォントサイズ</h5>
<ul>
{Object.entries(calculatedFontSizes).map(([size, value]) => (
<li key={size}>{size}: {value}px</li>
))}
</ul>
</div>
</div>
</div>
);
}
設定の永続化と同期
typescript// 設定の保存とロード
const useSettingsPersistence = () => {
const [settings, setSettings] = useAtom(appSettingsAtom);
// 設定の保存
const saveSettings = useCallback(() => {
try {
localStorage.setItem('appSettings', JSON.stringify(settings));
} catch (error) {
console.error('設定の保存に失敗しました:', error);
}
}, [settings]);
// 設定の読み込み
const loadSettings = useCallback(() => {
try {
const saved = localStorage.getItem('appSettings');
if (saved) {
const parsedSettings = JSON.parse(saved);
setSettings(parsedSettings);
}
} catch (error) {
console.error('設定の読み込みに失敗しました:', error);
}
}, [setSettings]);
// 自動保存の設定
useEffect(() => {
const timeoutId = setTimeout(saveSettings, 1000);
return () => clearTimeout(timeoutId);
}, [settings, saveSettings]);
return { saveSettings, loadSettings };
};
これらの実践例により、jotai/optics の強力さと柔軟性を理解していただけたでしょう。深いネスト構造でも、型安全性を保ちながら簡潔で保守性の高いコードを実現できます。
パフォーマンス最適化
不要な再レンダリング防止
jotai/optics を使用する際の最も重要な最適化ポイントは、不要な再レンダリングの防止です。適切な atom の分割と選択的な購読により、大幅なパフォーマンス向上を実現できます。
粒度の細かい atom 分割
typescript// ❌ 粒度が粗い atom - 全体が再レンダリングされる
const userDataAtom = atom({
profile: { name: 'John', age: 30 },
settings: { theme: 'light', language: 'en' },
stats: { loginCount: 100, lastLogin: '2024-01-01' }
});
// ✅ 細かく分割された atom - 必要な部分のみ再レンダリング
const userProfileAtom = focusAtom(userDataAtom, optic => optic.prop('profile'));
const userSettingsAtom = focusAtom(userDataAtom, optic => optic.prop('settings'));
const userStatsAtom = focusAtom(userDataAtom, optic => optic.prop('stats'));
// さらに細かい分割
const userNameAtom = focusAtom(userProfileAtom, optic => optic.prop('name'));
const userAgeAtom = focusAtom(userProfileAtom, optic => optic.prop('age'));
const themeAtom = focusAtom(userSettingsAtom, optic => optic.prop('theme'));
React.memo との組み合わせ
typescript// パフォーマンス最適化された子コンポーネント
const UserNameDisplay = React.memo(() => {
const name = useAtomValue(userNameAtom);
console.log('UserNameDisplay rendered'); // デバッグ用
return <h2>{name}</h2>;
});
const UserThemeToggle = React.memo(() => {
const [theme, setTheme] = useAtom(themeAtom);
console.log('UserThemeToggle rendered'); // デバッグ用
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
});
// この構成により、名前変更時にテーマコンポーネントは再レンダリングされない
function UserDashboard() {
return (
<div>
<UserNameDisplay /> {/* 名前変更時のみ再レンダリング */}
<UserThemeToggle /> {/* テーマ変更時のみ再レンダリング */}
</div>
);
}
選択的な値の購読
typescript// 複雑なオブジェクトから特定の値のみを購読
interface ComplexUserData {
id: string;
profile: {
personal: { name: string; email: string; };
professional: { title: string; company: string; };
};
activity: {
lastLogin: string;
sessionCount: number;
preferences: { [key: string]: any };
};
}
const complexUserAtom = atom<ComplexUserData>({
id: 'user1',
profile: {
personal: { name: 'Alice', email: 'alice@example.com' },
professional: { title: 'Developer', company: 'TechCorp' }
},
activity: {
lastLogin: '2024-01-01',
sessionCount: 50,
preferences: { theme: 'dark', lang: 'ja' }
}
});
// 必要な値のみに特化した atom
const userDisplayInfoAtom = atom(get => {
const user = get(complexUserAtom);
return {
name: user.profile.personal.name,
title: user.profile.professional.title,
company: user.profile.professional.company
};
});
// このコンポーネントは activity の変更では再レンダリングされない
const UserCard = React.memo(() => {
const { name, title, company } = useAtomValue(userDisplayInfoAtom);
return (
<div>
<h3>{name}</h3>
<p>{title} at {company}</p>
</div>
);
});
この図は atom の分割によるレンダリング最適化を示しています。
mermaidflowchart TD
A[Base User Atom] --> B[Profile Atom]
A --> C[Settings Atom]
A --> D[Stats Atom]
B --> E[Name Component]
B --> F[Age Component]
C --> G[Theme Component]
C --> H[Language Component]
D --> I[Stats Component]
J[Name Update] --> E
J -.->|再レンダリングなし| F
J -.->|再レンダリングなし| G
J -.->|再レンダリングなし| H
J -.->|再レンダリングなし| I
図で理解できる要点:
- atom の適切な分割により局所的な再レンダリングを実現
- 関連のない変更では他のコンポーネントは再レンダリングされない
- 細かい粒度の atom が最適なパフォーマンスを提供
メモ化戦略
複雑な計算や変換処理が必要な場合は、適切なメモ化戦略が重要です。
計算済み atom の活用
typescript// 基本データ
const salesDataAtom = atom([
{ date: '2024-01-01', amount: 1000, category: 'electronics' },
{ date: '2024-01-02', amount: 1500, category: 'books' },
{ date: '2024-01-03', amount: 800, category: 'electronics' },
// ... 大量のデータ
]);
const dateRangeAtom = atom({
start: '2024-01-01',
end: '2024-12-31'
});
// メモ化された集計 atom
const filteredSalesAtom = atom(get => {
const data = get(salesDataAtom);
const { start, end } = get(dateRangeAtom);
console.log('Filtering sales data...'); // 計算実行のログ
return data.filter(item =>
item.date >= start && item.date <= end
);
});
const salesSummaryAtom = atom(get => {
const filteredData = get(filteredSalesAtom);
console.log('Calculating sales summary...'); // 計算実行のログ
return {
total: filteredData.reduce((sum, item) => sum + item.amount, 0),
count: filteredData.length,
average: filteredData.length > 0
? filteredData.reduce((sum, item) => sum + item.amount, 0) / filteredData.length
: 0,
byCategory: filteredData.reduce((acc, item) => {
acc[item.category] = (acc[item.category] || 0) + item.amount;
return acc;
}, {} as Record<string, number>)
};
});
重い計算処理のメモ化
typescript// 計算コストの高い処理
const expensiveCalculationAtom = atom(get => {
const data = get(salesDataAtom);
const filters = get(filterSettingsAtom);
// 複雑な統計計算(例:回帰分析、トレンド分析等)
console.log('Performing expensive calculation...');
// この計算は依存する atom が変更されない限り再実行されない
return performComplexAnalysis(data, filters);
});
// カスタムフックでの使用
function useExpensiveData() {
return useAtomValue(expensiveCalculationAtom);
}
// データ変換のメモ化
const chartDataAtom = atom(get => {
const summary = get(salesSummaryAtom);
console.log('Converting to chart data...');
return {
labels: Object.keys(summary.byCategory),
datasets: [{
data: Object.values(summary.byCategory),
backgroundColor: generateColors(Object.keys(summary.byCategory).length)
}]
};
});
非同期処理のメモ化
typescript// API データの取得とキャッシュ
const userIdAtom = atom('user123');
const userProfileAsyncAtom = atom(async get => {
const userId = get(userIdAtom);
console.log(`Fetching user profile for ${userId}...`);
// この API 呼び出しは userId が変更されない限り再実行されない
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
// Suspense 対応コンポーネント
function UserProfileDisplay() {
const userProfile = useAtomValue(userProfileAsyncAtom);
return (
<div>
<h2>{userProfile.name}</h2>
<p>{userProfile.email}</p>
</div>
);
}
// エラーハンドリング付きの非同期 atom
const safeUserProfileAtom = atom(async get => {
try {
return await get(userProfileAsyncAtom);
} catch (error) {
console.error('Failed to fetch user profile:', error);
return { name: 'Unknown', email: 'Unknown' };
}
});
大規模データでの考慮事項
大量のデータを扱う場合の最適化戦略を紹介します。
仮想化とページネーション
typescript// 大規模データセット
interface LargeDataset {
items: Array<{
id: string;
name: string;
category: string;
metadata: Record<string, any>;
}>;
pagination: {
currentPage: number;
itemsPerPage: number;
totalItems: number;
};
filters: {
search: string;
category: string;
sortBy: string;
sortOrder: 'asc' | 'desc';
};
}
const largeDatasetAtom = atom<LargeDataset>({
items: [], // 実際には大量のデータ
pagination: { currentPage: 1, itemsPerPage: 50, totalItems: 0 },
filters: { search: '', category: '', sortBy: 'name', sortOrder: 'asc' }
});
// ページネーション atom
const paginationAtom = focusAtom(largeDatasetAtom, optic =>
optic.prop('pagination')
);
const filtersAtom = focusAtom(largeDatasetAtom, optic =>
optic.prop('filters')
);
// 現在のページのデータのみを計算
const currentPageDataAtom = atom(get => {
const { items } = get(largeDatasetAtom);
const { currentPage, itemsPerPage } = get(paginationAtom);
const filters = get(filtersAtom);
// フィルタリング
let filteredItems = items;
if (filters.search) {
filteredItems = filteredItems.filter(item =>
item.name.toLowerCase().includes(filters.search.toLowerCase())
);
}
if (filters.category) {
filteredItems = filteredItems.filter(item =>
item.category === filters.category
);
}
// ソート
filteredItems.sort((a, b) => {
const aValue = a[filters.sortBy as keyof typeof a];
const bValue = b[filters.sortBy as keyof typeof b];
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return filters.sortOrder === 'asc' ? comparison : -comparison;
});
// ページネーション
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
return {
items: filteredItems.slice(startIndex, endIndex),
totalFilteredItems: filteredItems.length
};
});
遅延読み込み戦略
typescript// 遅延読み込み用の atom ファクトリ
const createLazyDataAtom = <T>(
key: string,
fetcher: () => Promise<T>
) => {
const loadingAtom = atom(false);
const dataAtom = atom<T | null>(null);
const errorAtom = atom<Error | null>(null);
const lazyAtom = atom(
get => ({
data: get(dataAtom),
loading: get(loadingAtom),
error: get(errorAtom)
}),
async (get, set) => {
const currentData = get(dataAtom);
if (currentData !== null) return; // 既にロード済み
set(loadingAtom, true);
set(errorAtom, null);
try {
const data = await fetcher();
set(dataAtom, data);
} catch (error) {
set(errorAtom, error as Error);
} finally {
set(loadingAtom, false);
}
}
);
return lazyAtom;
};
// 使用例
const heavyDataAtom = createLazyDataAtom(
'heavyData',
async () => {
const response = await fetch('/api/heavy-data');
return response.json();
}
);
function HeavyDataComponent() {
const [{ data, loading, error }, loadData] = useAtom(heavyDataAtom);
useEffect(() => {
loadData(); // マウント時に読み込み開始
}, [loadData]);
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
if (!data) return <div>データがありません</div>;
return <div>{/* データの表示 */}</div>;
}
メモリ使用量の最適化
typescript// WeakMap を使用したメモリ効率的なキャッシュ
const createWeakMapAtom = <K extends object, V>(
keyAtom: Atom<K>,
valueComputer: (key: K) => V
) => {
const cache = new WeakMap<K, V>();
return atom(get => {
const key = get(keyAtom);
if (!cache.has(key)) {
cache.set(key, valueComputer(key));
}
return cache.get(key)!;
});
};
// ガベージコレクション対応の atom
const createDisposableAtom = <T>(
initialValue: T,
disposer?: (value: T) => void
) => {
const baseAtom = atom(initialValue);
return atom(
get => get(baseAtom),
(get, set, newValue: T) => {
const oldValue = get(baseAtom);
if (disposer) {
disposer(oldValue);
}
set(baseAtom, newValue);
}
);
};
// 定期的なクリーンアップ
const createCleanupAtom = <T>(
dataAtom: Atom<T[]>,
maxItems: number = 1000
) => {
return atom(
get => get(dataAtom),
(get, set, newItem: T) => {
const currentData = get(dataAtom);
const newData = [...currentData, newItem];
// アイテム数が上限を超えた場合、古いアイテムを削除
if (newData.length > maxItems) {
newData.splice(0, newData.length - maxItems);
}
set(dataAtom, newData);
}
);
};
パフォーマンス監視
typescript// パフォーマンス測定用のカスタムフック
const usePerformanceMonitor = (atomName: string) => {
const startTime = useRef<number>();
useEffect(() => {
startTime.current = performance.now();
return () => {
if (startTime.current) {
const duration = performance.now() - startTime.current;
if (duration > 16) { // 16ms を超える場合は警告
console.warn(`${atomName} rendering took ${duration.toFixed(2)}ms`);
}
}
};
});
};
// 使用例
function MonitoredComponent() {
usePerformanceMonitor('HeavyDataComponent');
const data = useAtomValue(heavyDataAtom);
return <div>{/* コンポーネントの内容 */}</div>;
}
これらの最適化手法により、大規模なアプリケーションでも jotai/optics の優れたパフォーマンスを維持できます。適切な atom 設計とメモ化戦略が、スケーラブルな状態管理の鍵となります。
まとめ
jotai/optics は、React アプリケーションにおける深いネスト状態管理の課題を解決する革新的なライブラリです。本記事で解説した内容を振り返り、実践に向けたポイントをまとめます。
主要な利点の再確認
jotai/optics の導入により、以下の具体的な改善を実現できます。
項目 | 従来手法 | jotai/optics | 改善効果 |
---|---|---|---|
コード量 | 15-25行(深いネスト) | 3-5行 | 80-85%削減 |
型安全性 | 実行時エラーリスク | コンパイル時保証 | エラー率大幅減少 |
可読性 | スプレッド演算子多用 | 直感的なパス指定 | 理解容易性向上 |
保守性 | 構造変更時の影響大 | 局所的な修正 | メンテナンス効率化 |
実装時の重要ポイント
1. 適切な atom 設計
typescript// ✅ 推奨:目的別に分割された atom
const userBasicInfoAtom = focusAtom(userAtom, optic =>
optic.prop('personal').prop('basicInfo')
);
const userPreferencesAtom = focusAtom(userAtom, optic =>
optic.prop('preferences')
);
// ❌ 非推奨:過度に細かい分割
const firstLetterOfFirstNameAtom = focusAtom(userAtom, optic =>
optic.prop('personal').prop('basicInfo').prop('firstName').at(0)
);
2. パフォーマンス考慮事項
- React.memo との組み合わせで不要な再レンダリングを防止
- 計算済み atom により重い処理のメモ化を実現
- 適切な粒度での atom 分割が性能の鍵
3. エラーハンドリング戦略
typescript// 型ガードと optional chaining の活用
const safeEmailAtom = focusAtom(userAtom, optic =>
optic.optional()
.prop('personal')
.optional()
.prop('contact')
.optional()
.prop('email')
);
段階的な導入アプローチ
Phase 1: 限定的な導入
既存プロジェクトでは、最も複雑な状態管理部分から段階的に導入することを推奨します。
typescript// 既存の複雑な状態更新を置き換え
// Before
const updateUserPreference = (key: string, value: any) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
[key]: value
}
}));
};
// After
const preferenceAtom = focusAtom(userAtom, optic =>
optic.prop('preferences').prop(key)
);
const setPreference = useSetAtom(preferenceAtom);
Phase 2: 新機能での全面活用
新しい機能開発では、設計段階から jotai/optics を前提とした atom 設計を行います。
Phase 3: レガシーコードのリファクタリング
安定した状態で、段階的にレガシーコードを jotai/optics ベースに移行していきます。
今後の発展と継続学習
jotai/optics エコシステムは活発に発展しており、以下の分野での更なる進化が期待されます。
新機能の活用
- Prismatic optics による条件付きアクセス
- Traversal optics による配列操作の高度化
- Custom optics による独自ロジックの統合
コミュニティとの連携
- GitHub の Issue や Discussion への参加
- 実装パターンの共有とフィードバック
- パフォーマンス改善の事例共有
最後に
jotai/optics は、単なる状態管理ライブラリを超えて、React アプリケーション開発の思考パターンを変革するツールです。深いネスト構造への恐怖心を取り除き、より表現力豊かで保守性の高いコードの実現を支援します。
本記事で紹介した実践的なパターンを参考に、ぜひプロジェクトでの導入を検討してみてください。初期の学習コストを上回る、長期的な開発効率の向上を実感できるはずです。
型安全で直感的な状態管理により、開発者は本来集中すべき機能開発に注力でき、結果として質の高いユーザー体験の提供につながるでしょう。
関連リンク
- article
jotai/optics で深いネストを安全に操作する実践ガイド
- article
React Server Components 時代に Jotai はどう進化する?サーバーとクライアントをまたぐ状態管理の未来
- article
Jotai を 120%活用するユーティリティライブラリ(jotai-immer, jotai-xstate, jotai-form)の世界
- article
Redux Toolkit から Jotai への移行は可能か?具体的なリファクタリング戦略とコード例
- article
Context API の再レンダリング地獄から Jotai へ。移行メリットとステップバイステップガイド
- article
大規模アプリケーションにおける Jotai 設計考察 - どの状態を atom にし、どこに配置すべきか
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来