T-CREATOR

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

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 を独立してテスト可能

他の状態管理ライブラリとの比較

主要な状態管理ライブラリとの比較を表で整理してみましょう。

特徴JotaiRedux ToolkitZustandRecoil
学習コスト
ボイラープレート最小
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 の柔軟性と各プラットフォームの特性を理解し、適切に活用することで、保守性が高く、スケーラブルな状態管理システムを構築できるでしょう。ぜひ、実際のプロジェクトでこれらのテクニックを活用してみてください。

関連リンク