Jotai セットアップ完全レシピ:Vite/Next.js/React Native 横断対応

モダンな React アプリケーション開発において、状態管理は避けて通れない重要な課題です。特に、Vite、Next.js、React Native といった異なるプラットフォーム間で一貫した状態管理を実現したい場面も多いでしょう。
そんな中で注目を集めているのが「Jotai」です。今回は、Jotai の基本概念から各プラットフォームでの具体的なセットアップ方法まで、実践的なレシピとして詳しく解説いたします。
Jotai とは
Jotai は、Meta(旧 Facebook)のエンジニアによって開発された、革新的な状態管理ライブラリです。従来の Redux や Zustand とは異なるアプローチで、より柔軟で直感的な状態管理を実現します。
状態管理ライブラリの特徴
Jotai の最大の特徴は「bottom-up」アプローチにあります。これは従来の「top-down」な状態管理とは対照的な考え方です。
以下の図で、Jotai のアーキテクチャ概要を確認してみましょう。
mermaidflowchart TD
atoms[Atoms<br/>原子的な状態] -->|組み合わせ| derived[Derived Atoms<br/>派生した状態]
atoms --> components[React Components<br/>コンポーネント]
derived --> components
components -->|更新| atoms
subgraph "Traditional State Management"
store[Global Store] --> slice1[State Slice 1]
store --> slice2[State Slice 2]
slice1 --> comp1[Component A]
slice2 --> comp2[Component B]
end
subgraph "Jotai Approach"
atoms
derived
components
end
Jotai では、グローバルストアを作成する代わりに、小さな「atom」を組み合わせて状態を構築します。これにより以下のメリットが生まれます:
- ボイラープレートの削減: アクションやリデューサーが不要
- 型安全性: TypeScript との親和性が高い
- パフォーマンス: 必要な部分のみが再レンダリング
- テスタビリティ: 個別の atom を独立してテスト可能
他の状態管理ライブラリとの比較
主要な状態管理ライブラリとの比較を表で整理してみましょう。
特徴 | Jotai | Redux Toolkit | Zustand | Recoil |
---|---|---|---|---|
学習コスト | 低 | 高 | 低 | 中 |
ボイラープレート | 最小 | 中 | 少 | 少 |
TypeScript 対応 | 優秀 | 良好 | 良好 | 普通 |
バンドルサイズ | 小 (2.4kb) | 大 (10.9kb) | 小 (2.6kb) | 中 (6.2kb) |
開発者体験 | 優秀 | 良好 | 良好 | 良好 |
生態系 | 成長中 | 成熟 | 成長中 | 実験的 |
Jotai は特に、小〜中規模のプロジェクトや、型安全性を重視する開発チームに適しています。
基本的な Jotai の概念
Jotai を効果的に活用するために、まずは核となる概念を理解しましょう。
Atom の仕組み
Atom は、Jotai における状態の最小単位です。アトミック(原子的)な値を表現し、他の atom と組み合わせて複雑な状態を構築できます。
基本的な atom の作成方法を見てみましょう:
javascriptimport { atom } from 'jotai';
// プリミティブなatom
const countAtom = atom(0);
const nameAtom = atom('太郎');
const isLoadingAtom = atom(false);
atom の定義では、初期値を指定します。この初期値の型から、TypeScript が自動的に型を推論してくれます。
typescript// TypeScript環境での型推論
const userAtom = atom({
id: 1,
name: '太郎',
email: 'taro@example.com',
});
// userAtomの型は Atom<{id: number, name: string, email: string}> になる
atom を使用するコンポーネントでは、useAtom
フックを使用します:
javascriptimport { useAtom } from 'jotai';
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>現在のカウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
カウントアップ
</button>
</div>
);
}
この例では、useAtom
が React のuseState
と似た API を提供していることがわかります。違いは、状態がコンポーネント外で定義され、複数のコンポーネント間で共有できることです。
プリミティブとデリバティブ
Jotai では、atom を 2 つのカテゴリに分類できます。
mermaidflowchart LR
subgraph "Primitive Atoms"
prim1["countAtom<br/>atom(0)"]
prim2["nameAtom<br/>atom('太郎')"]
prim3["itemsAtom<br/>atom([])"]
end
subgraph "Derived Atoms"
derived1["doubleCountAtom<br/>get => count * 2"]
derived2["displayNameAtom<br/>get => Hello, name"]
derived3["itemCountAtom<br/>get => items.length"]
end
prim1 --> derived1
prim2 --> derived2
prim3 --> derived3
**プリミティブ Atom(原始的な atom)**は、直接値を保持する atom です:
javascript// プリミティブatomの例
const countAtom = atom(0);
const userAtom = atom({ name: '太郎', age: 25 });
const todosAtom = atom([]);
**デリバティブ Atom(派生 atom)**は、他の atom から計算される読み取り専用の atom です:
javascript// 読み取り専用のderivative atom
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 複数のatomを組み合わせた例
const userDisplayAtom = atom((get) => {
const user = get(userAtom);
const count = get(countAtom);
return `${user.name}さん (${count}回訪問)`;
});
より複雑な例として、読み書き可能な derivative atom も作成できます:
javascript// 読み書き可能なderivative atom
const filteredTodosAtom = atom(
// getter: 完了していないタスクのみを返す
(get) => get(todosAtom).filter((todo) => !todo.completed),
// setter: 新しいタスクを追加
(get, set, newTodo) => {
const currentTodos = get(todosAtom);
set(todosAtom, [...currentTodos, newTodo]);
}
);
このように、Jotai ではシンプルな atom を組み合わせて、複雑な状態ロジックを表現できます。この設計により、状態管理コードの再利用性とテスタビリティが大幅に向上します。
Vite プロジェクトでのセットアップ
Vite は、高速な開発体験を提供するビルドツールです。Jotai と Vite の組み合わせは、モダンな React 開発において非常に強力な選択肢となります。
プロジェクト作成とインストール
まず、新しい Vite プロジェクトを作成し、必要なパッケージをインストールしましょう。
bash# Viteプロジェクトの作成
yarn create vite jotai-vite-app --template react-ts
cd jotai-vite-app
プロジェクトが作成されたら、Jotai と関連パッケージをインストールします:
bash# Jotai本体のインストール
yarn add jotai
# 開発用ツール(オプション)
yarn add -D @jotai/devtools
@jotai/devtools
は、ブラウザの開発者ツールで Jotai の状態を監視できる便利なライブラリです。開発時の効率向上に大きく貢献します。
基本設定
Vite プロジェクトのsrc/main.tsx
を編集して、Jotai の基本セットアップを行います:
typescriptimport React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'jotai';
import App from './App.tsx';
import './index.css';
次に、Jotai Provider でアプリケーション全体をラップします:
typescriptReactDOM.createRoot(
document.getElementById('root')!
).render(
<React.StrictMode>
<Provider>
<App />
</Provider>
</React.StrictMode>
);
Jotai では、Provider
の使用は必須ではありませんが、以下の場合には使用を推奨します:
- テスト時に状態を分離したい
- 複数のアプリケーションインスタンスを同じページで動かす
- サーバーサイドレンダリングを使用する
基本的な atom を定義してみましょう:
typescript// src/atoms/index.ts
import { atom } from 'jotai';
// ユーザー情報のatom
export const userAtom = atom({
name: '',
email: '',
});
// カウンターのatom
export const countAtom = atom(0);
// ローディング状態のatom
export const isLoadingAtom = atom(false);
TypeScript 設定
Vite での TypeScript 設定は、tsconfig.json
で適切な設定を行うことが重要です:
json{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
パスエイリアスの設定により、atom のインポートが簡潔になります:
typescript// パスエイリアス使用前
import { userAtom } from '../../../atoms/user';
// パスエイリアス使用後
import { userAtom } from '@/atoms/user';
Vite の設定ファイル(vite.config.ts
)も更新します:
typescriptimport { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
開発環境の最適化
Vite 環境で Jotai を効率的に開発するための設定を行います。
まず、開発者ツールの設定です:
typescript// src/components/DevTools.tsx (開発環境のみ)
import { DevTools } from '@jotai/devtools';
export function JotaiDevTools() {
return <>{import.meta.env.DEV && <DevTools />}</>;
}
メインアプリケーションに組み込みます:
typescript// src/App.tsx
import { JotaiDevTools } from './components/DevTools';
import { useAtom } from 'jotai';
import { countAtom } from './atoms';
function App() {
const [count, setCount] = useAtom(countAtom);
return (
<div className='App'>
<h1>Jotai with Vite</h1>
<p>カウント: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>
増加
</button>
<JotaiDevTools />
</div>
);
}
export default App;
Hot Module Replacement(HMR)の最適化のため、atom の定義は別ファイルに分離することを推奨します:
typescript// src/atoms/counter.ts
import { atom } from 'jotai';
export const countAtom = atom(0);
// 派生atomの例
export const doubleCountAtom = atom(
(get) => get(countAtom) * 2
);
export const countDisplayAtom = atom(
(get) => `現在のカウント: ${get(countAtom)}`
);
この設定により、Vite 環境で Jotai を効率的に開発できる環境が整いました。次に、Next.js でのセットアップを見ていきましょう。
Next.js プロジェクトでのセットアップ
Next.js は、React ベースのフルスタックフレームワークとして、サーバーサイドレンダリング(SSR)や静的サイト生成(SSG)機能を提供します。Jotai を Next.js で使用する際は、これらの機能を考慮した設定が必要です。
App Router と Pages Router での設定
Next.js 13 以降では、App Router が推奨されていますが、Pages Router も引き続きサポートされています。それぞれでの Jotai セットアップ方法を説明します。
App Router での設定
まず、Next.js プロジェクトを作成し、Jotai をインストールします:
bash# Next.jsプロジェクトの作成
npx create-next-app@latest jotai-nextjs-app --typescript --tailwind --eslint
cd jotai-nextjs-app
# Jotaiのインストール
yarn add jotai
App Router では、app/layout.tsx
で Jotai Provider を設定します:
typescript// app/layout.tsx
'use client';
import { Provider } from 'jotai';
import { ReactNode } from 'react';
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({
children,
}: RootLayoutProps) {
return (
<html lang='ja'>
<body>
<Provider>{children}</Provider>
</body>
</html>
);
}
注意点として、App Router では'use client'
ディレクティブが必要です。これは、Jotai Provider がクライアントサイドコンポーネントだからです。
Pages Router での設定
Pages Router を使用する場合は、pages/_app.tsx
で設定を行います:
typescript// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Provider } from 'jotai';
export default function App({
Component,
pageProps,
}: AppProps) {
return (
<Provider>
<Component {...pageProps} />
</Provider>
);
}
共通の atom を定義しておきましょう:
typescript// lib/atoms/index.ts
import { atom } from 'jotai';
// ユーザー認証状態
export const userAtom = atom<{
id: string;
name: string;
email: string;
} | null>(null);
// ページローディング状態
export const pageLoadingAtom = atom(false);
// テーマ設定
export const themeAtom = atom<'light' | 'dark'>('light');
SSR/SSG での考慮事項
Next.js で Jotai を使用する際の最大の課題は、サーバーサイドとクライアントサイドの状態管理です。
mermaidsequenceDiagram
participant Server
participant Client
participant Jotai
Note over Server,Jotai: SSR/SSG Phase
Server->>Server: Initial HTML generation
Server->>Server: Atom initial values set
Server->>Client: HTML + hydration data
Note over Client,Jotai: Hydration Phase
Client->>Jotai: Initialize atoms with server values
Client->>Jotai: Hydrate components
Jotai->>Client: Client-side state ready
Note over Client,Jotai: Client-side Phase
Client->>Jotai: User interactions
Jotai->>Client: State updates
ハイドレーション時の状態不整合を避けるため、サーバーとクライアントで初期値を統一する必要があります:
typescript// lib/atoms/hydration.ts
import { atom } from 'jotai';
// サーバーサイドでの初期値設定
export const hydratedAtom = atom(false);
// クライアントサイドでハイドレーション完了を管理
export const useHydration = () => {
const [hydrated, setHydrated] = useAtom(hydratedAtom);
useEffect(() => {
setHydrated(true);
}, [setHydrated]);
return hydrated;
};
具体的なコンポーネントでの使用例:
typescript// components/ThemeToggle.tsx
'use client';
import { useAtom } from 'jotai';
import { themeAtom } from '@/lib/atoms';
import { useHydration } from '@/lib/atoms/hydration';
export function ThemeToggle() {
const hydrated = useHydration();
const [theme, setTheme] = useAtom(themeAtom);
if (!hydrated) {
// ハイドレーション完了まではプレースホルダーを表示
return (
<div className='w-20 h-8 bg-gray-200 animate-pulse rounded' />
);
}
return (
<button
onClick={() =>
setTheme(theme === 'light' ? 'dark' : 'light')
}
className='px-4 py-2 rounded bg-blue-500 text-white'
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}
ミドルウェアとの連携
Next.js のミドルウェア機能と Jotai を連携させることで、認証状態やロケール情報をグローバルに管理できます。
typescript// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// ユーザー情報をヘッダーに設定
const userInfo = request.cookies.get('user-info');
if (userInfo) {
response.headers.set('x-user-info', userInfo.value);
}
// ロケール情報を設定
const locale =
request.cookies.get('locale')?.value || 'ja';
response.headers.set('x-locale', locale);
return response;
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
ミドルウェアから渡された情報を atom で管理:
typescript// lib/atoms/middleware.ts
import { atom } from 'jotai';
// ミドルウェアから渡されたユーザー情報
export const serverUserAtom = atom<string | null>(null);
// ロケール情報
export const localeAtom = atom<string>('ja');
// サーバー情報を元にした派生atom
export const parsedUserAtom = atom((get) => {
const userInfoStr = get(serverUserAtom);
return userInfoStr ? JSON.parse(userInfoStr) : null;
});
レイアウトコンポーネントでサーバー情報を初期化:
typescript// app/layout.tsx
'use client';
import { Provider, useSetAtom } from 'jotai';
import {
serverUserAtom,
localeAtom,
} from '@/lib/atoms/middleware';
import { useEffect } from 'react';
function ServerDataInitializer() {
const setServerUser = useSetAtom(serverUserAtom);
const setLocale = useSetAtom(localeAtom);
useEffect(() => {
// ヘッダーからサーバー情報を取得
fetch('/api/server-info')
.then((res) => res.json())
.then((data) => {
setServerUser(data.userInfo);
setLocale(data.locale);
});
}, [setServerUser, setLocale]);
return null;
}
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<Provider>
<ServerDataInitializer />
{children}
</Provider>
</body>
</html>
);
}
この設定により、Next.js の強力な SSR/SSG 機能と Jotai の柔軟な状態管理を効果的に組み合わせることができます。
React Native プロジェクトでのセットアップ
React Native では、Web アプリケーションとは異なる考慮点があります。ネイティブプラットフォームの特性を活かしながら、Jotai の利点を最大限に引き出すセットアップを行いましょう。
Expo 環境での設定
Expo は、React Native 開発を簡素化するプラットフォームです。まず、新しい Expo プロジェクトを作成します:
bash# Expoプロジェクトの作成
npx create-expo-app JotaiReactNativeApp --template blank-typescript
cd JotaiReactNativeApp
# Jotaiのインストール
yarn add jotai
# React Native固有の依存関係
yarn add @react-native-async-storage/async-storage
Expo プロジェクトのApp.tsx
で Jotai を初期化します:
typescript// App.tsx
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { Provider } from 'jotai';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import MainApp from './src/MainApp';
export default function App() {
return (
<SafeAreaProvider>
<Provider>
<MainApp />
<StatusBar style='auto' />
</Provider>
</SafeAreaProvider>
);
}
React Native 固有の atom を定義します:
typescript// src/atoms/native.ts
import { atom } from 'jotai';
import AsyncStorage from '@react-native-async-storage/async-storage';
// デバイス情報のatom
export const deviceInfoAtom = atom({
platform: '',
version: '',
isTablet: false,
});
// 永続化されたユーザー設定
export const userPreferencesAtom = atom(
// getter
async (get) => {
try {
const stored = await AsyncStorage.getItem(
'userPreferences'
);
return stored
? JSON.parse(stored)
: {
theme: 'light',
notifications: true,
language: 'ja',
};
} catch {
return {
theme: 'light',
notifications: true,
language: 'ja',
};
}
},
// setter
async (get, set, newPreferences) => {
try {
await AsyncStorage.setItem(
'userPreferences',
JSON.stringify(newPreferences)
);
} catch (error) {
console.error('設定の保存に失敗しました:', error);
}
}
);
Native CLI 環境での設定
React Native CLI を使用する場合は、より詳細な設定が必要になります:
bash# React Native CLIプロジェクトの作成
npx react-native init JotaiRNApp --template react-native-template-typescript
cd JotaiRNApp
# 必要なパッケージのインストール
yarn add jotai
yarn add @react-native-async-storage/async-storage
# iOS依存関係のインストール(macOSの場合)
cd ios && pod install && cd ..
index.js
を編集して Jotai Provider を設定:
javascript// index.js
import { AppRegistry } from 'react-native';
import { Provider } from 'jotai';
import App from './src/App';
import { name as appName } from './app.json';
const JotaiApp = () => (
<Provider>
<App />
</Provider>
);
AppRegistry.registerComponent(appName, () => JotaiApp);
Metro 設定で TypeScript パスエイリアスを有効化:
javascript// metro.config.js
const path = require('path');
module.exports = {
resolver: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: true,
},
}),
},
};
プラットフォーム固有の設定
React Native では、iOS・Android・Web(React Native Web 使用時)で動作が異なる場合があります。プラットフォーム別の設定を適切に管理しましょう。
mermaidflowchart TD
subgraph "Platform Detection"
platform[Platform.OS] --> ios[iOS]
platform --> android[Android]
platform --> web[Web]
end
subgraph "Jotai Atoms"
ios --> iosAtoms[iOS専用atoms]
android --> androidAtoms[Android専用atoms]
web --> webAtoms[Web専用atoms]
iosAtoms --> sharedAtoms[共通atoms]
androidAtoms --> sharedAtoms
webAtoms --> sharedAtoms
end
sharedAtoms --> components[React Components]
プラットフォーム固有の atom を作成:
typescript// src/atoms/platform.ts
import { atom } from 'jotai';
import { Platform, Dimensions } from 'react-native';
// プラットフォーム情報のatom
export const platformAtom = atom({
os: Platform.OS,
version: Platform.Version,
isTV: Platform.isTV,
isTesting: Platform.isTesting,
});
// デバイス寸法のatom
export const dimensionsAtom = atom(() => {
const { width, height } = Dimensions.get('window');
const { width: screenWidth, height: screenHeight } =
Dimensions.get('screen');
return {
window: { width, height },
screen: { width: screenWidth, height: screenHeight },
isLandscape: width > height,
};
});
// プラットフォーム別のストレージ戦略
export const storageStrategyAtom = atom(() => {
switch (Platform.OS) {
case 'ios':
return 'keychain'; // iOS Keychainを使用
case 'android':
return 'encrypted-storage'; // Android Encrypted Storageを使用
case 'web':
return 'local-storage'; // ブラウザのlocalStorageを使用
default:
return 'memory'; // フォールバック
}
});
プラットフォーム固有の機能を使用するカスタムフック:
typescript// src/hooks/useNativeFeatures.ts
import { useAtom } from 'jotai';
import {
platformAtom,
storageStrategyAtom,
} from '@/atoms/platform';
import { Alert, Vibration } from 'react-native';
export function useNativeFeatures() {
const [platform] = useAtom(platformAtom);
const [storageStrategy] = useAtom(storageStrategyAtom);
const showAlert = (title: string, message: string) => {
Alert.alert(title, message);
};
const vibrate = (pattern: number[] = [100]) => {
if (platform.os !== 'web') {
Vibration.vibrate(pattern);
}
};
const getStorageKey = (key: string) => {
return `${storageStrategy}_${key}`;
};
return {
platform,
showAlert,
vibrate,
getStorageKey,
isNative: platform.os !== 'web',
};
}
実際のコンポーネントでの使用例:
typescript// src/components/PlatformAwareComponent.tsx
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useAtom } from 'jotai';
import { useNativeFeatures } from '@/hooks/useNativeFeatures';
import { userPreferencesAtom } from '@/atoms/native';
export function PlatformAwareComponent() {
const { platform, showAlert, vibrate, isNative } =
useNativeFeatures();
const [preferences, setPreferences] = useAtom(
userPreferencesAtom
);
const handlePress = async () => {
if (isNative) {
vibrate([50, 100, 50]); // ネイティブでのみ振動
}
showAlert(
'プラットフォーム情報',
`現在のプラットフォーム: ${platform.os}`
);
// 設定を更新
await setPreferences({
...preferences,
lastUsed: new Date().toISOString(),
});
};
return (
<View style={{ padding: 20 }}>
<Text style={{ fontSize: 18, marginBottom: 20 }}>
プラットフォーム: {platform.os} ({platform.version})
</Text>
<TouchableOpacity
onPress={handlePress}
style={{
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
alignItems: 'center',
}}
>
<Text
style={{ color: 'white', fontWeight: 'bold' }}
>
プラットフォーム機能をテスト
</Text>
</TouchableOpacity>
</View>
);
}
これらの設定により、React Native 環境で Jotai の力を最大限に活用できる基盤が整いました。次は、プラットフォーム間で共通のベストプラクティスについて見ていきましょう。
共通設定とベストプラクティス
複数のプラットフォーム間で Jotai を効果的に活用するためには、一貫した設計原則とベストプラクティスの適用が重要です。
TypeScript 型定義の統一
まず、全プラットフォームで共通の Type 定義を作成しましょう:
typescript// types/shared.ts - 共通型定義
export interface User {
id: string;
name: string;
email: string;
avatar?: string;
preferences: UserPreferences;
}
export interface UserPreferences {
theme: 'light' | 'dark' | 'system';
language: 'ja' | 'en';
notifications: boolean;
privacy: PrivacySettings;
}
export interface PrivacySettings {
shareAnalytics: boolean;
shareUsageData: boolean;
allowMarketing: boolean;
}
// APIレスポンスの型
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
timestamp: string;
}
// 非同期状態の型
export interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
lastUpdated?: string;
}
共通の Utility 型も定義しておくと便利です:
typescript// types/utilities.ts
// Jotai用のAtom型ヘルパー
export type AtomValue<T> = T extends Atom<infer V>
? V
: never;
export type WritableAtom<T> = Atom<T> & {
write: (value: T) => void;
};
// 非同期処理用の型
export type AsyncAtomState<T> = {
state: 'idle' | 'loading' | 'success' | 'error';
data?: T;
error?: string;
};
// フォーム状態の型
export type FormState<T> = {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isValid: boolean;
isSubmitting: boolean;
};
これらの型を使用した統一された atom 定義:
typescript// atoms/shared.ts - プラットフォーム共通のatom
import { atom } from 'jotai';
import type {
User,
ApiResponse,
AsyncState,
} from '@/types/shared';
// ユーザー状態のatom
export const userAtom = atom<AsyncState<User>>({
data: null,
loading: false,
error: null,
});
// 認証状態のatom
export const authAtom = atom<{
isAuthenticated: boolean;
token: string | null;
expiresAt: string | null;
}>({
isAuthenticated: false,
token: null,
expiresAt: null,
});
// アプリケーション設定のatom
export const appConfigAtom = atom<{
apiBaseUrl: string;
version: string;
environment: 'development' | 'staging' | 'production';
features: Record<string, boolean>;
}>({
apiBaseUrl: process.env.API_BASE_URL || '',
version: process.env.APP_VERSION || '1.0.0',
environment:
(process.env.NODE_ENV as any) || 'development',
features: {},
});
デバッグツールの設定
効率的な開発のために、統一されたデバッグ環境を構築しましょう。
mermaidflowchart LR
subgraph "Development Tools"
devtools[Jotai DevTools]
logger[Console Logger]
inspector[State Inspector]
end
subgraph "Platform Support"
web[Web Browser]
rn[React Native Debugger]
native[Flipper/Native Tools]
end
devtools --> web
logger --> rn
inspector --> native
subgraph "Common Interface"
debug[Debug Wrapper]
end
web --> debug
rn --> debug
native --> debug
プラットフォーム対応のデバッグラッパーを作成:
typescript// utils/debug.ts
import { Atom } from 'jotai';
// プラットフォーム検出
const isWeb = typeof window !== 'undefined';
const isReactNative =
typeof navigator !== 'undefined' &&
navigator.product === 'ReactNative';
interface DebugConfig {
enabled: boolean;
prefix: string;
includeTimestamp: boolean;
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
export class JotaiDebugger {
private config: DebugConfig;
constructor(config: Partial<DebugConfig> = {}) {
this.config = {
enabled: process.env.NODE_ENV === 'development',
prefix: '[Jotai]',
includeTimestamp: true,
logLevel: 'debug',
...config,
};
}
logAtomUpdate<T>(
atom: Atom<T>,
oldValue: T,
newValue: T
) {
if (!this.config.enabled) return;
const timestamp = this.config.includeTimestamp
? new Date().toISOString()
: '';
const message = `${this.config.prefix} ${timestamp} Atom updated:`;
if (isWeb && window.console?.group) {
console.group(message);
console.log('Atom:', atom);
console.log('Old value:', oldValue);
console.log('New value:', newValue);
console.groupEnd();
} else {
console.log(message, { atom, oldValue, newValue });
}
}
logAtomRead<T>(atom: Atom<T>, value: T) {
if (!this.config.enabled) return;
const message = `${this.config.prefix} Atom read:`;
console.log(message, { atom, value });
}
}
// グローバルデバッガーインスタンス
export const jotaiDebugger = new JotaiDebugger();
デバッグ機能付きのカスタムフックを作成:
typescript// hooks/useAtomWithDebug.ts
import { useAtom } from 'jotai';
import { Atom } from 'jotai';
import { jotaiDebugger } from '@/utils/debug';
import { useEffect, useRef } from 'react';
export function useAtomWithDebug<T>(atom: Atom<T>) {
const [value, setValue] = useAtom(atom);
const previousValue = useRef(value);
useEffect(() => {
if (previousValue.current !== value) {
jotaiDebugger.logAtomUpdate(
atom,
previousValue.current,
value
);
previousValue.current = value;
}
}, [atom, value]);
useEffect(() => {
jotaiDebugger.logAtomRead(atom, value);
}, [atom, value]);
return [value, setValue] as const;
}
テスト環境の構築
プラットフォーム横断のテスト戦略を確立しましょう:
typescript// utils/test-utils.tsx
import {
render,
RenderOptions,
} from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { Provider } from 'jotai';
// テスト用のカスタムProvider
interface CustomProviderProps {
children: ReactNode;
initialValues?: Array<[any, any]>;
}
function CustomProvider({
children,
initialValues = [],
}: CustomProviderProps) {
return (
<Provider initialValues={initialValues}>
{children}
</Provider>
);
}
// カスタムrender関数
interface CustomRenderOptions
extends Omit<RenderOptions, 'wrapper'> {
initialValues?: Array<[any, any]>;
}
export function renderWithJotai(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { initialValues, ...renderOptions } = options;
const Wrapper = ({
children,
}: {
children: ReactNode;
}) => (
<CustomProvider initialValues={initialValues}>
{children}
</CustomProvider>
);
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// テスト用のatom factory
export function createTestAtom<T>(initialValue: T) {
return atom(initialValue);
}
// 非同期atomのテスト用ヘルパー
export function createAsyncTestAtom<T>(
promise: Promise<T>,
initialValue: T | null = null
) {
return atom(async () => {
try {
return await promise;
} catch (error) {
throw error;
}
});
}
実際のテストファイルの例:
typescript// __tests__/atoms/user.test.ts
import { waitFor } from '@testing-library/react';
import { renderWithJotai } from '@/utils/test-utils';
import { userAtom, authAtom } from '@/atoms/shared';
import { useAtom } from 'jotai';
// テスト用コンポーネント
function TestComponent() {
const [user] = useAtom(userAtom);
const [auth] = useAtom(authAtom);
return (
<div>
<div data-testid='user-name'>
{user.data?.name || 'No user'}
</div>
<div data-testid='auth-status'>
{auth.isAuthenticated
? 'Authenticated'
: 'Not authenticated'}
</div>
</div>
);
}
describe('User Atoms', () => {
it('should initialize with default values', () => {
const { getByTestId } = renderWithJotai(
<TestComponent />
);
expect(getByTestId('user-name')).toHaveTextContent(
'No user'
);
expect(getByTestId('auth-status')).toHaveTextContent(
'Not authenticated'
);
});
it('should accept initial values', () => {
const initialUser = {
data: {
id: '1',
name: 'テストユーザー',
email: 'test@example.com',
},
loading: false,
error: null,
};
const { getByTestId } = renderWithJotai(
<TestComponent />,
{
initialValues: [
[userAtom, initialUser],
[
authAtom,
{
isAuthenticated: true,
token: 'test-token',
expiresAt: null,
},
],
],
}
);
expect(getByTestId('user-name')).toHaveTextContent(
'テストユーザー'
);
expect(getByTestId('auth-status')).toHaveTextContent(
'Authenticated'
);
});
});
Jest 設定ファイル(jest.config.js
):
javascriptmodule.exports = {
preset: 'react-native', // React Nativeの場合
// preset: 'ts-jest', // Web/Next.jsの場合
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{ts,tsx}',
],
};
この統一されたテスト・デバッグ環境により、すべてのプラットフォームで一貫した品質を保つことができます。
プラットフォーム横断での状態共有
最後に、異なるプラットフォーム間で効率的に状態を共有する方法について詳しく説明します。
共通 Atom の設計
プラットフォーム間で状態を共有するには、適切に抽象化された atom の設計が重要です。
mermaidflowchart TD
subgraph "Shared Atoms Layer"
coreAtoms[Core Business Logic Atoms]
dataAtoms[Data Management Atoms]
configAtoms[Configuration Atoms]
end
subgraph "Platform Abstraction Layer"
webAdapter[Web Adapter]
nextAdapter[Next.js Adapter]
rnAdapter[React Native Adapter]
end
subgraph "Platform Implementation"
webImpl[Web Implementation]
nextImpl[Next.js Implementation]
rnImpl[React Native Implementation]
end
coreAtoms --> webAdapter
coreAtoms --> nextAdapter
coreAtoms --> rnAdapter
dataAtoms --> webAdapter
dataAtoms --> nextAdapter
dataAtoms --> rnAdapter
configAtoms --> webAdapter
configAtoms --> nextAdapter
configAtoms --> rnAdapter
webAdapter --> webImpl
nextAdapter --> nextImpl
rnAdapter --> rnImpl
まず、ビジネスロジックに特化したコア atom を定義します:
typescript// atoms/core/business.ts
import { atom } from 'jotai';
import type { User, Product, Order } from '@/types/shared';
// ユーザー管理のコアatom
export const coreUserAtom = atom<{
current: User | null;
preferences: UserPreferences;
permissions: string[];
}>({
current: null,
preferences: {
theme: 'system',
language: 'ja',
notifications: true,
privacy: {
shareAnalytics: false,
shareUsageData: false,
allowMarketing: false,
},
},
permissions: [],
});
// ショッピングカートのコアatom
export const coreCartAtom = atom<{
items: CartItem[];
total: number;
currency: string;
}>({
items: [],
total: 0,
currency: 'JPY',
});
// 製品管理のコアatom
export const coreProductsAtom = atom<{
items: Product[];
categories: Category[];
filters: ProductFilters;
}>({
items: [],
categories: [],
filters: {
category: null,
priceRange: { min: 0, max: 100000 },
inStock: true,
},
});
次に、プラットフォーム固有の機能を抽象化するアダプターを作成:
typescript// adapters/storage.ts
// ストレージの抽象化
export interface StorageAdapter {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
}
// Web/Next.js用実装
export class WebStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
async setItem(key: string, value: string): Promise<void> {
try {
localStorage.setItem(key, value);
} catch {
// サイレントに失敗
}
}
async removeItem(key: string): Promise<void> {
try {
localStorage.removeItem(key);
} catch {
// サイレントに失敗
}
}
async clear(): Promise<void> {
try {
localStorage.clear();
} catch {
// サイレントに失敗
}
}
}
// React Native用実装
export class ReactNativeStorageAdapter
implements StorageAdapter
{
async getItem(key: string): Promise<string | null> {
try {
const AsyncStorage =
require('@react-native-async-storage/async-storage').default;
return await AsyncStorage.getItem(key);
} catch {
return null;
}
}
async setItem(key: string, value: string): Promise<void> {
try {
const AsyncStorage =
require('@react-native-async-storage/async-storage').default;
await AsyncStorage.setItem(key, value);
} catch {
// サイレントに失敗
}
}
async removeItem(key: string): Promise<void> {
try {
const AsyncStorage =
require('@react-native-async-storage/async-storage').default;
await AsyncStorage.removeItem(key);
} catch {
// サイレントに失敗
}
}
async clear(): Promise<void> {
try {
const AsyncStorage =
require('@react-native-async-storage/async-storage').default;
await AsyncStorage.clear();
} catch {
// サイレントに失敗
}
}
}
環境固有の分岐処理
プラットフォーム間での差異を効率的に管理する仕組みを構築します:
typescript// utils/platform-factory.ts
// プラットフォーム検出とファクトリーパターン
export type Platform = 'web' | 'nextjs' | 'react-native';
export function detectPlatform(): Platform {
// Next.js環境の検出
if (typeof window !== 'undefined' && window.next) {
return 'nextjs';
}
// React Native環境の検出
if (
typeof navigator !== 'undefined' &&
navigator.product === 'ReactNative'
) {
return 'react-native';
}
// Web環境の検出
if (typeof window !== 'undefined') {
return 'web';
}
// デフォルトはWeb
return 'web';
}
// ファクトリー関数
export function createPlatformAdapter<T>(implementations: {
web: () => T;
nextjs: () => T;
'react-native': () => T;
}): T {
const platform = detectPlatform();
return implementations[platform]();
}
プラットフォーム固有の atom 実装:
typescript// atoms/platform-specific.ts
import { atom } from 'jotai';
import { createPlatformAdapter } from '@/utils/platform-factory';
import {
WebStorageAdapter,
ReactNativeStorageAdapter,
} from '@/adapters/storage';
// ストレージアダプターの作成
const storageAdapter = createPlatformAdapter({
web: () => new WebStorageAdapter(),
nextjs: () => new WebStorageAdapter(),
'react-native': () => new ReactNativeStorageAdapter(),
});
// 永続化されたユーザー設定のatom
export const persistentUserPreferencesAtom = atom(
// getter - ストレージから設定を読み込み
async (get) => {
try {
const stored = await storageAdapter.getItem(
'userPreferences'
);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
},
// setter - ストレージに設定を保存
async (get, set, newPreferences) => {
try {
await storageAdapter.setItem(
'userPreferences',
JSON.stringify(newPreferences)
);
} catch (error) {
console.error('設定の保存に失敗しました:', error);
}
}
);
// プラットフォーム固有の機能を使用するatom
export const platformFeaturesAtom = atom(() => {
return createPlatformAdapter({
web: () => ({
hasCamera: 'mediaDevices' in navigator,
hasGeolocation: 'geolocation' in navigator,
hasPushNotifications:
'serviceWorker' in navigator &&
'PushManager' in window,
canVibrate: 'vibrate' in navigator,
}),
nextjs: () => ({
hasCamera:
typeof navigator !== 'undefined' &&
'mediaDevices' in navigator,
hasGeolocation:
typeof navigator !== 'undefined' &&
'geolocation' in navigator,
hasPushNotifications:
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window,
canVibrate: false, // Next.jsでは通常無効
}),
'react-native': () => ({
hasCamera: true, // React Nativeでは通常利用可能
hasGeolocation: true,
hasPushNotifications: true,
canVibrate: true,
}),
});
});
統合的なカスタムフックの作成:
typescript// hooks/usePlatformState.ts
import { useAtom } from 'jotai';
import {
coreUserAtom,
persistentUserPreferencesAtom,
platformFeaturesAtom,
} from '@/atoms';
import { detectPlatform } from '@/utils/platform-factory';
import { useEffect, useState } from 'react';
export function usePlatformState() {
const [user, setUser] = useAtom(coreUserAtom);
const [preferences, setPreferences] = useAtom(
persistentUserPreferencesAtom
);
const [features] = useAtom(platformFeaturesAtom);
const [platform] = useState(detectPlatform);
// 初期化時に永続化された設定を読み込み
useEffect(() => {
const initializePreferences = async () => {
const stored = await preferences;
if (stored && user.current) {
setUser({
...user,
preferences: { ...user.preferences, ...stored },
});
}
};
initializePreferences();
}, []);
// 設定変更時に自動保存
useEffect(() => {
if (user.current?.preferences) {
setPreferences(user.current.preferences);
}
}, [user.current?.preferences]);
return {
user,
setUser,
platform,
features,
// プラットフォーム固有のユーティリティ関数
updatePreferences: (
newPrefs: Partial<UserPreferences>
) => {
if (user.current) {
setUser({
...user,
current: {
...user.current,
preferences: {
...user.current.preferences,
...newPrefs,
},
},
});
}
},
// プラットフォーム機能の確認
canUseFeature: (feature: keyof typeof features) => {
return features[feature];
},
};
}
実際の使用例:
typescript// components/UniversalSettings.tsx
import React from 'react';
import { usePlatformState } from '@/hooks/usePlatformState';
// Web/Next.js用のコンポーネント
function WebSettingsToggle({
label,
value,
onChange,
}: {
label: string;
value: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<input
type='checkbox'
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
);
}
// React Native用のコンポーネント(条件的インポート)
const ReactNativeComponents = React.lazy(() =>
import('./ReactNativeSettings').then((module) => ({
default: module.ReactNativeSettingsToggle,
}))
);
export function UniversalSettings() {
const {
user,
updatePreferences,
platform,
canUseFeature,
} = usePlatformState();
if (!user.current) {
return <div>ログインしてください</div>;
}
const { preferences } = user.current;
// プラットフォーム別のレンダリング
const renderToggle = (
label: string,
value: boolean,
onChange: (value: boolean) => void
) => {
if (platform === 'react-native') {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<ReactNativeComponents
label={label}
value={value}
onChange={onChange}
/>
</React.Suspense>
);
}
return (
<WebSettingsToggle
label={label}
value={value}
onChange={onChange}
/>
);
};
return (
<div style={{ padding: '20px' }}>
<h2>設定 ({platform})</h2>
{renderToggle(
'テーマを自動切り替え',
preferences.theme === 'system',
(value) =>
updatePreferences({
theme: value ? 'system' : 'light',
})
)}
{canUseFeature('hasPushNotifications') &&
renderToggle(
'通知を有効化',
preferences.notifications,
(value) =>
updatePreferences({ notifications: value })
)}
{renderToggle(
'分析データを共有',
preferences.privacy.shareAnalytics,
(value) =>
updatePreferences({
privacy: {
...preferences.privacy,
shareAnalytics: value,
},
})
)}
<div
style={{
marginTop: '20px',
fontSize: '12px',
color: '#666',
}}
>
プラットフォーム: {platform}
<br />
利用可能な機能:{' '}
{Object.entries(canUseFeature)
.map(([key, enabled]) => (enabled ? key : null))
.filter(Boolean)
.join(', ')}
</div>
</div>
);
}
まとめ
この記事では、Jotai を使った状態管理を、Vite・Next.js・React Native の各プラットフォームで効率的に実装する方法を詳しく解説いたしました。
図で理解できる要点
- Jotai の bottom-up アーキテクチャにより、小さな atom を組み合わせて柔軟な状態管理が実現できる
- プラットフォーム固有の機能は抽象化レイヤーを通じて統一的に管理できる
- 共通のコア atom と適応層の設計により、コードの再利用性が大幅に向上する
各プラットフォームでの具体的なセットアップ手順から、プラットフォーム横断での状態共有まで、実践的なコード例とともにご紹介しました。特に重要なのは以下のポイントです:
Vite 環境では、高速な開発体験と TypeScript の恩恵を最大限に活用できます。HMR との組み合わせにより、atom の変更がすぐに反映される開発環境が構築できました。
Next.js 環境では、SSR と SSG の特性を考慮した状態管理が必要です。ハイドレーション時の状態不整合を避けるための工夫や、ミドルウェアとの連携により、本格的な Web アプリケーションで活用できる設計をご紹介しました。
React Native 環境では、プラットフォーム固有の機能(ストレージ、デバイス情報など)との連携が重要です。Expo と React Native CLI の両方での設定方法と、ネイティブ機能を活用した atom 設計を解説いたしました。
共通設定とベストプラクティスでは、型安全性を保ちながらプラットフォーム間でコードを共有する方法をお示ししました。統一されたテスト環境とデバッグツールにより、開発効率と品質の両立が可能になります。
Jotai の柔軟性と各プラットフォームの特性を理解し、適切に活用することで、保守性が高く、スケーラブルな状態管理システムを構築できるでしょう。ぜひ、実際のプロジェクトでこれらのテクニックを活用してみてください。
関連リンク
- article
Jotai セットアップ完全レシピ:Vite/Next.js/React Native 横断対応
- article
Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解
- article
状態遷移を明文化する:XState × Jotai の堅牢な非同期フロー設計
- article
Undo/Redo を備えた履歴つき状態管理を Jotai で設計する
- article
BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する
- article
jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)
- article
Web Components を Vite + TypeScript + yarn で最短セットアップする完全手順
- article
Lodash を部分インポートで導入する最短ルート:ESM/TS/バンドラ別の設定集
- article
LangChain を Edge で走らせる:Cloudflare Workers/Deno/Bun 対応の初期配線
- article
Vue.js の Hydration mismatch を潰す:SSR/CSR 差異の原因 12 と実践対策
- article
Jotai セットアップ完全レシピ:Vite/Next.js/React Native 横断対応
- article
Tailwind CSS が反映されない時の総点検:content 設定・JIT・パージの落とし穴
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来