T-CREATOR

Jotai のプロバイダーパターン完全攻略 - Provider の使い方とベストプラクティス

Jotai のプロバイダーパターン完全攻略 - Provider の使い方とベストプラクティス

React アプリケーションの状態管理において、Jotai の atom は非常に強力な仕組みですが、より柔軟で実用的な状態管理を実現するためには「Provider パターン」の理解が不可欠です。

Provider は、atom の初期値をカスタマイズしたり、状態のスコープを制御したり、さらにはサーバーサイドレンダリングに対応したりと、実際のプロダクション環境で欠かせない機能を提供してくれます。しかし、Provider の適切な使い方やベストプラクティスについて体系的に学べる情報は意外と少ないのが現状でしょう。

本記事では、Jotai の Provider パターンを完全攻略するために、基本的な概念から実践的な活用方法、さらには大規模アプリケーションでの設計パターンまで、幅広く解説していきます。Provider を使いこなすことで、より保守性が高く、スケーラブルな React アプリケーションを構築できるようになることでしょう。

実際のコード例を豊富に交えながら、Provider の真の力を引き出すためのテクニックとノウハウをお伝えします。これまで atom だけで状態管理していた方も、Provider の可能性を知ることで、アプリケーション設計の幅が大きく広がるはずです。

Jotai Provider の基本理解

Provider とは何か?なぜ必要なのか

Jotai の Provider は、React の Context API をベースとした仕組みで、atom の状態を特定のコンポーネントツリー内でスコープ化し、カスタマイズすることを可能にします。これにより、デフォルトでは全てグローバルな atom に対して、より細やかな制御を行うことができます。

Provider の主要な役割は以下の通りです:

typescriptimport { Provider, atom, useAtom } from 'jotai';

// グローバルなatom定義
const countAtom = atom(0);
const nameAtom = atom('');

function App() {
  return (
    <div>
      {/* Provider なしの領域 */}
      <GlobalCounter />

      {/* Provider ありの領域 */}
      <Provider>
        <ScopedCounter />
      </Provider>

      {/* 別のProvider領域 */}
      <Provider>
        <AnotherScopedCounter />
      </Provider>
    </div>
  );
}

function GlobalCounter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <h3>グローバルカウンター</h3>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
}

function ScopedCounter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <h3>スコープ化されたカウンター</h3>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
}

function AnotherScopedCounter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <h3>別のスコープカウンター</h3>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>
        +1
      </button>
    </div>
  );
}

この例では、同じ countAtom を使用していても、Provider で囲まれた領域ではそれぞれ独立した状態が管理されます。つまり、3 つのカウンターは別々の値を持つことになります。

Provider なしの制限と課題

Provider を使用しない場合、Jotai の atom は全てグローバルスコープで管理されるため、いくつかの制限と課題が発生します。

初期値の固定化

typescript// atom定義時に初期値が固定される
const userAtom = atom<User | null>(null);
const settingsAtom = atom<Settings>({
  theme: 'light',
  language: 'ja',
  notifications: true,
});

// サーバーサイドで取得したデータを初期値として設定できない
function UserProfile({
  serverUserData,
}: {
  serverUserData: User;
}) {
  const [user, setUser] = useAtom(userAtom);

  // 初回レンダリング時にサーバーデータを手動で設定する必要がある
  useEffect(() => {
    if (serverUserData && !user) {
      setUser(serverUserData);
    }
  }, [serverUserData, user, setUser]);

  return <div>{user?.name || 'Loading...'}</div>;
}

テスト時の状態隔離の困難さ

typescriptdescribe('UserProfile Component', () => {
  beforeEach(() => {
    // 全テストケース間でatomの状態がグローバルに共有される
    // 手動でリセットする必要がある
  });

  it('should display user name', async () => {
    // テストケース1でuserAtomに値をセット
    const { result } = renderHook(() => useAtom(userAtom));
    act(() => {
      result.current[1]({ id: 1, name: 'Test User' });
    });

    // このテストの実行後、userAtomにはTest Userが残る
  });

  it('should handle empty user', () => {
    // 前のテストの影響でuserAtomにはまだTest Userが入っている
    // 予期しない動作を引き起こす可能性がある
  });
});

マルチテナント対応の複雑さ

typescript// グローバルatomでは組織やユーザー別の状態分離が困難
const organizationAtom = atom<Organization | null>(null);
const membersAtom = atom<Member[]>([]);

function OrganizationDashboard({
  orgId,
}: {
  orgId: string;
}) {
  const [org, setOrg] = useAtom(organizationAtom);
  const [members, setMembers] = useAtom(membersAtom);

  useEffect(() => {
    // 組織切り替え時に手動で状態をクリアする必要がある
    setOrg(null);
    setMembers([]);

    fetchOrganization(orgId).then(setOrg);
    fetchMembers(orgId).then(setMembers);
  }, [orgId]);

  // 他の組織の情報が残っている可能性がある
  return <div>Organization: {org?.name}</div>;
}

Provider ありの利点と可能性

Provider を活用することで、これらの制限を解消し、より柔軟で実用的な状態管理が実現できます。

動的な初期値設定

typescriptinterface AppProviderProps {
  children: React.ReactNode;
  initialUser?: User;
  initialSettings?: Settings;
}

function AppProvider({
  children,
  initialUser,
  initialSettings,
}: AppProviderProps) {
  const initialValues: [Atom<any>, any][] = [];

  if (initialUser) {
    initialValues.push([userAtom, initialUser]);
  }

  if (initialSettings) {
    initialValues.push([settingsAtom, initialSettings]);
  }

  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

// サーバーサイドで取得したデータを初期値として設定
function App({ serverData }: { serverData: ServerData }) {
  return (
    <AppProvider
      initialUser={serverData.user}
      initialSettings={serverData.settings}
    >
      <UserProfile />
      <UserSettings />
    </AppProvider>
  );
}

function UserProfile() {
  const [user] = useAtom(userAtom);

  // 初回レンダリングからserverDataが利用可能
  return <div>Welcome, {user?.name}!</div>;
}

テスト時の完全な状態隔離

typescriptfunction TestProvider({
  children,
  initialValues = [],
}: {
  children: React.ReactNode;
  initialValues?: [Atom<any>, any][];
}) {
  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

describe('UserProfile Component', () => {
  it('should display user name', () => {
    const testUser = { id: 1, name: 'Test User' };

    render(
      <TestProvider initialValues={[[userAtom, testUser]]}>
        <UserProfile />
      </TestProvider>
    );

    expect(
      screen.getByText('Welcome, Test User!')
    ).toBeInTheDocument();
    // テスト終了後、グローバル状態は影響を受けない
  });

  it('should handle empty user', () => {
    render(
      <TestProvider>
        <UserProfile />
      </TestProvider>
    );

    // 前のテストの影響を受けない、クリーンな状態でテスト
    expect(
      screen.getByText('Welcome, Guest!')
    ).toBeInTheDocument();
  });
});

マルチテナント・マルチユーザー対応

typescriptfunction OrganizationProvider({
  children,
  organizationId,
}: {
  children: React.ReactNode;
  organizationId: string;
}) {
  const [initialOrg, setInitialOrg] =
    useState<Organization | null>(null);
  const [initialMembers, setInitialMembers] = useState<
    Member[]
  >([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function loadOrganizationData() {
      setIsLoading(true);
      try {
        const [org, members] = await Promise.all([
          fetchOrganization(organizationId),
          fetchMembers(organizationId),
        ]);
        setInitialOrg(org);
        setInitialMembers(members);
      } finally {
        setIsLoading(false);
      }
    }

    loadOrganizationData();
  }, [organizationId]);

  if (isLoading) {
    return <div>Loading organization data...</div>;
  }

  return (
    <Provider
      initialValues={[
        [organizationAtom, initialOrg],
        [membersAtom, initialMembers],
      ]}
    >
      {children}
    </Provider>
  );
}

function OrganizationSwitcher() {
  const [currentOrgId, setCurrentOrgId] = useState('org-1');

  return (
    <div>
      <select
        value={currentOrgId}
        onChange={(e) => setCurrentOrgId(e.target.value)}
      >
        <option value='org-1'>Organization 1</option>
        <option value='org-2'>Organization 2</option>
        <option value='org-3'>Organization 3</option>
      </select>

      {/* 組織切り替え時に自動的に新しいProviderスコープが作成される */}
      <OrganizationProvider organizationId={currentOrgId}>
        <OrganizationDashboard />
      </OrganizationProvider>
    </div>
  );
}

Provider の基本的な使い方

最小構成での Provider 設定

最もシンプルな Provider の使用方法から始めましょう。基本的には、React の Provider と同様に、対象となるコンポーネントツリーを Provider で囲むだけです。

typescriptimport { Provider, atom, useAtom } from 'jotai';

const messageAtom = atom('Hello World');

function App() {
  return (
    <Provider>
      <MessageDisplay />
      <MessageEditor />
    </Provider>
  );
}

function MessageDisplay() {
  const [message] = useAtom(messageAtom);
  return <h1>{message}</h1>;
}

function MessageEditor() {
  const [message, setMessage] = useAtom(messageAtom);

  return (
    <input
      value={message}
      onChange={(e) => setMessage(e.target.value)}
    />
  );
}

この最小構成では、Provider 内で使用される atom は、Provider のスコープ内で独立した状態を持ちます。Provider の外側で同じ atom を使用した場合は、グローバルスコープの状態にアクセスすることになります。

Provider の props と設定オプション

Provider には、様々な設定オプションを渡すことができます。最も重要なのは initialValues プロパティです。

typescriptimport { Provider, createStore } from 'jotai';

// カスタムストアの作成
const customStore = createStore();

function ConfigurableProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      // 初期値の設定
      initialValues={[
        [messageAtom, 'Custom Initial Message'],
        [countAtom, 100],
        [userAtom, { id: 1, name: 'Default User' }],
      ]}
      // カスタムストアの使用
      store={customStore}
    >
      {children}
    </Provider>
  );
}

initialValues の詳細な使い方

typescriptinterface User {
  id: number;
  name: string;
  email: string;
}

interface Settings {
  theme: 'light' | 'dark';
  language: string;
  notifications: boolean;
}

const userAtom = atom<User | null>(null);
const settingsAtom = atom<Settings>({
  theme: 'light',
  language: 'en',
  notifications: true,
});
const countAtom = atom(0);

// 型安全な初期値設定のヘルパー関数
function createInitialValues(config: {
  user?: User;
  settings?: Partial<Settings>;
  count?: number;
}) {
  const initialValues: [any, any][] = [];

  if (config.user) {
    initialValues.push([userAtom, config.user]);
  }

  if (config.settings) {
    const currentSettings = {
      theme: 'light' as const,
      language: 'en',
      notifications: true,
      ...config.settings,
    };
    initialValues.push([settingsAtom, currentSettings]);
  }

  if (config.count !== undefined) {
    initialValues.push([countAtom, config.count]);
  }

  return initialValues;
}

function CustomProvider({
  children,
  config,
}: {
  children: React.ReactNode;
  config: Parameters<typeof createInitialValues>[0];
}) {
  const initialValues = createInitialValues(config);

  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

// 使用例
function App() {
  return (
    <CustomProvider
      config={{
        user: {
          id: 1,
          name: '田中太郎',
          email: 'tanaka@example.com',
        },
        settings: { theme: 'dark', language: 'ja' },
        count: 50,
      }}
    >
      <Dashboard />
    </CustomProvider>
  );
}

atom の初期値設定とカスタマイズ

Provider を使用することで、atom の初期値を動的に設定したり、環境に応じてカスタマイズしたりできます。

環境別初期値設定

typescriptinterface EnvironmentConfig {
  apiEndpoint: string;
  debugMode: boolean;
  theme: 'light' | 'dark';
}

const apiEndpointAtom = atom('');
const debugModeAtom = atom(false);
const themeAtom = atom<'light' | 'dark'>('light');

function EnvironmentProvider({
  children,
  environment,
}: {
  children: React.ReactNode;
  environment: 'development' | 'staging' | 'production';
}) {
  const config: EnvironmentConfig = useMemo(() => {
    switch (environment) {
      case 'development':
        return {
          apiEndpoint: 'http://localhost:3000/api',
          debugMode: true,
          theme: 'dark',
        };
      case 'staging':
        return {
          apiEndpoint: 'https://staging-api.example.com',
          debugMode: true,
          theme: 'light',
        };
      case 'production':
        return {
          apiEndpoint: 'https://api.example.com',
          debugMode: false,
          theme: 'light',
        };
      default:
        throw new Error(
          `Unknown environment: ${environment}`
        );
    }
  }, [environment]);

  return (
    <Provider
      initialValues={[
        [apiEndpointAtom, config.apiEndpoint],
        [debugModeAtom, config.debugMode],
        [themeAtom, config.theme],
      ]}
    >
      {children}
    </Provider>
  );
}

// Next.js での使用例
function MyApp({ Component, pageProps }: AppProps) {
  const environment = process.env.NODE_ENV as
    | 'development'
    | 'staging'
    | 'production';

  return (
    <EnvironmentProvider environment={environment}>
      <Component {...pageProps} />
    </EnvironmentProvider>
  );
}

動的初期値の算出

typescriptconst userPreferencesAtom = atom<UserPreferences>({
  theme: 'system',
  language: 'auto',
  timezone: 'auto',
});

function UserPreferencesProvider({
  children,
  userId,
}: {
  children: React.ReactNode;
  userId?: string;
}) {
  const [initialPreferences, setInitialPreferences] =
    useState<UserPreferences | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function loadUserPreferences() {
      setIsLoading(true);

      try {
        if (userId) {
          // ユーザー固有の設定を取得
          const userPrefs = await fetchUserPreferences(
            userId
          );
          setInitialPreferences(userPrefs);
        } else {
          // ゲストユーザーの場合は、ブラウザの設定から推測
          const browserPrefs: UserPreferences = {
            theme: window.matchMedia(
              '(prefers-color-scheme: dark)'
            ).matches
              ? 'dark'
              : 'light',
            language: navigator.language.split('-')[0],
            timezone:
              Intl.DateTimeFormat().resolvedOptions()
                .timeZone,
          };
          setInitialPreferences(browserPrefs);
        }
      } catch (error) {
        console.error(
          'Failed to load user preferences:',
          error
        );
        // フォールバック設定
        setInitialPreferences({
          theme: 'light',
          language: 'en',
          timezone: 'UTC',
        });
      } finally {
        setIsLoading(false);
      }
    }

    loadUserPreferences();
  }, [userId]);

  if (isLoading || !initialPreferences) {
    return <div>Loading preferences...</div>;
  }

  return (
    <Provider
      initialValues={[
        [userPreferencesAtom, initialPreferences],
      ]}
    >
      {children}
    </Provider>
  );
}

複数 Provider の組み合わせパターン

ネストした Provider の設計

複数の Provider をネストすることで、階層的な状態管理を実現できます。これは、アプリケーションの機能やスコープに応じて状態を分離したい場合に特に有用です。

typescript// グローバルレベルのatom
const appConfigAtom = atom({
  version: '1.0.0',
  maintenance: false,
});

// ユーザーレベルのatom
const userAtom = atom<User | null>(null);
const userSettingsAtom = atom<UserSettings>({});

// セッションレベルのatom
const currentPageAtom = atom('home');
const navigationHistoryAtom = atom<string[]>([]);

function AppProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [
          appConfigAtom,
          {
            version: '1.0.0',
            maintenance: false,
          },
        ],
      ]}
    >
      {children}
    </Provider>
  );
}

function UserProvider({
  children,
  user,
}: {
  children: React.ReactNode;
  user: User | null;
}) {
  return (
    <Provider
      initialValues={[
        [userAtom, user],
        [userSettingsAtom, user?.settings || {}],
      ]}
    >
      {children}
    </Provider>
  );
}

function SessionProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [currentPageAtom, 'home'],
        [navigationHistoryAtom, []],
      ]}
    >
      {children}
    </Provider>
  );
}

// ネストした Provider の構成
function App() {
  const { user } = useAuth();

  return (
    <AppProvider>
      <UserProvider user={user}>
        <SessionProvider>
          <Router>
            <Header />
            <main>
              <Routes>
                <Route path='/' element={<HomePage />} />
                <Route
                  path='/profile'
                  element={<ProfilePage />}
                />
                <Route
                  path='/settings'
                  element={<SettingsPage />}
                />
              </Routes>
            </main>
          </Router>
        </SessionProvider>
      </UserProvider>
    </AppProvider>
  );
}

Provider 階層での状態の継承と上書き

typescriptconst themeAtom = atom<'light' | 'dark'>('light');
const primaryColorAtom = atom('#007bff');

function GlobalThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [themeAtom, 'light'],
        [primaryColorAtom, '#007bff'],
      ]}
    >
      {children}
    </Provider>
  );
}

function DarkThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [themeAtom, 'dark'],
        [primaryColorAtom, '#ffc107'],
      ]}
    >
      {children}
    </Provider>
  );
}

function ThemedSection({
  useDarkTheme = false,
  children,
}: {
  useDarkTheme?: boolean;
  children: React.ReactNode;
}) {
  if (useDarkTheme) {
    return (
      <DarkThemeProvider>{children}</DarkThemeProvider>
    );
  }

  return <>{children}</>;
}

function App() {
  return (
    <GlobalThemeProvider>
      <div>
        <h1>グローバルテーマセクション</h1>
        <ThemeDisplay />

        <ThemedSection useDarkTheme>
          <h2>ダークテーマセクション</h2>
          <ThemeDisplay />
        </ThemedSection>

        <h3>再びグローバルテーマ</h3>
        <ThemeDisplay />
      </div>
    </GlobalThemeProvider>
  );
}

function ThemeDisplay() {
  const [theme] = useAtom(themeAtom);
  const [primaryColor] = useAtom(primaryColorAtom);

  return (
    <div
      style={{
        background: theme === 'dark' ? '#333' : '#fff',
        color: theme === 'dark' ? '#fff' : '#333',
        border: `2px solid ${primaryColor}`,
        padding: '1rem',
        margin: '0.5rem 0',
      }}
    >
      Theme: {theme}, Primary Color: {primaryColor}
    </div>
  );
}

機能別 Provider の分離戦略

大規模なアプリケーションでは、機能やドメインごとに Provider を分離することで、保守性と可読性を向上させることができます。

typescript// 認証関連のatom
const authUserAtom = atom<AuthUser | null>(null);
const authTokenAtom = atom<string | null>(null);
const authStatusAtom = atom<
  'idle' | 'loading' | 'authenticated' | 'error'
>('idle');

// 通知関連のatom
const notificationsAtom = atom<Notification[]>([]);
const unreadCountAtom = atom(0);
const notificationSettingsAtom = atom<NotificationSettings>(
  {}
);

// データ取得関連のatom
const apiCacheAtom = atom<Map<string, any>>(new Map());
const loadingStatesAtom = atom<Map<string, boolean>>(
  new Map()
);
const errorStatesAtom = atom<Map<string, Error | null>>(
  new Map()
);

// 認証Provider
function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [initialAuthState, setInitialAuthState] = useState<{
    user: AuthUser | null;
    token: string | null;
    status: 'idle' | 'loading' | 'authenticated' | 'error';
  } | null>(null);

  useEffect(() => {
    // 保存された認証情報の復元
    async function restoreAuth() {
      try {
        const token = localStorage.getItem('auth_token');
        if (token) {
          const user = await validateToken(token);
          setInitialAuthState({
            user,
            token,
            status: 'authenticated',
          });
        } else {
          setInitialAuthState({
            user: null,
            token: null,
            status: 'idle',
          });
        }
      } catch (error) {
        setInitialAuthState({
          user: null,
          token: null,
          status: 'error',
        });
      }
    }

    restoreAuth();
  }, []);

  if (!initialAuthState) {
    return <div>Initializing auth...</div>;
  }

  return (
    <Provider
      initialValues={[
        [authUserAtom, initialAuthState.user],
        [authTokenAtom, initialAuthState.token],
        [authStatusAtom, initialAuthState.status],
      ]}
    >
      {children}
    </Provider>
  );
}

// 通知Provider
function NotificationProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [notificationsAtom, []],
        [unreadCountAtom, 0],
        [
          notificationSettingsAtom,
          {
            email: true,
            push: true,
            inApp: true,
          },
        ],
      ]}
    >
      {children}
    </Provider>
  );
}

// データ管理Provider
function DataProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [apiCacheAtom, new Map()],
        [loadingStatesAtom, new Map()],
        [errorStatesAtom, new Map()],
      ]}
    >
      {children}
    </Provider>
  );
}

// 機能別Providerの組み合わせ
function FeatureProvidersWrapper({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <AuthProvider>
      <NotificationProvider>
        <DataProvider>{children}</DataProvider>
      </NotificationProvider>
    </AuthProvider>
  );
}

Provider 間のデータ共有と独立性

異なる Provider スコープ間でデータを共有したい場合や、逆に完全に独立させたい場合の戦略について説明します。

Provider 間でのデータ共有

typescript// 共有したいatom(グローバルレベル)
const globalNotificationAtom = atom<string | null>(null);

// Provider固有のatom
const userDashboardAtom = atom<DashboardData>({});
const adminDashboardAtom = atom<AdminDashboardData>({});

function UserDashboardProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [
          userDashboardAtom,
          {
            widgets: ['weather', 'calendar', 'tasks'],
            layout: 'grid',
            preferences: {},
          },
        ],
      ]}
    >
      {children}
    </Provider>
  );
}

function AdminDashboardProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider
      initialValues={[
        [
          adminDashboardAtom,
          {
            metrics: {},
            users: [],
            systemStatus: 'healthy',
            permissions: ['read', 'write', 'admin'],
          },
        ],
      ]}
    >
      {children}
    </Provider>
  );
}

// 共有する通知コンポーネント
function GlobalNotification() {
  const [notification] = useAtom(globalNotificationAtom);

  if (!notification) return null;

  return <div className='notification'>{notification}</div>;
}

// 異なるProvider内のコンポーネント
function UserDashboard() {
  const [dashboard] = useAtom(userDashboardAtom);
  const [, setGlobalNotification] = useAtom(
    globalNotificationAtom
  );

  const handleAction = () => {
    // Provider固有の状態を更新
    // ユーザーダッシュボードの更新処理...

    // グローバル通知を設定(他のProviderからも見える)
    setGlobalNotification(
      'ユーザーダッシュボードが更新されました'
    );
  };

  return (
    <div>
      <h2>ユーザーダッシュボード</h2>
      <button onClick={handleAction}>アクション実行</button>
      <GlobalNotification />
    </div>
  );
}

function AdminDashboard() {
  const [dashboard] = useAtom(adminDashboardAtom);
  const [, setGlobalNotification] = useAtom(
    globalNotificationAtom
  );

  const handleSystemUpdate = () => {
    // Admin固有の状態を更新
    // システム更新処理...

    // グローバル通知を設定
    setGlobalNotification('システムが更新されました');
  };

  return (
    <div>
      <h2>管理者ダッシュボード</h2>
      <button onClick={handleSystemUpdate}>
        システム更新
      </button>
      <GlobalNotification />
    </div>
  );
}

完全な独立性の確保

typescript// 完全に独立させたいatom
const organizationDataAtom = atom<OrganizationData | null>(
  null
);
const membersAtom = atom<Member[]>([]);

function OrganizationProvider({
  organizationId,
  children,
}: {
  organizationId: string;
  children: React.ReactNode;
}) {
  // 組織IDをキーとして、完全に独立したProviderを作成
  return (
    <Provider
      key={organizationId} // 重要: keyでProviderインスタンスを区別
      initialValues={[
        [organizationDataAtom, null],
        [membersAtom, []],
      ]}
    >
      <OrganizationLoader organizationId={organizationId}>
        {children}
      </OrganizationLoader>
    </Provider>
  );
}

function OrganizationLoader({
  organizationId,
  children,
}: {
  organizationId: string;
  children: React.ReactNode;
}) {
  const [, setOrganizationData] = useAtom(
    organizationDataAtom
  );
  const [, setMembers] = useAtom(membersAtom);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function loadData() {
      setIsLoading(true);
      try {
        const [orgData, memberData] = await Promise.all([
          fetchOrganization(organizationId),
          fetchMembers(organizationId),
        ]);
        setOrganizationData(orgData);
        setMembers(memberData);
      } finally {
        setIsLoading(false);
      }
    }

    loadData();
  }, [organizationId, setOrganizationData, setMembers]);

  if (isLoading) {
    return (
      <div>Loading organization {organizationId}...</div>
    );
  }

  return <>{children}</>;
}

// 複数の組織を同時に表示
function MultiOrganizationView() {
  const [selectedOrgs] = useState([
    'org-1',
    'org-2',
    'org-3',
  ]);

  return (
    <div style={{ display: 'flex', gap: '1rem' }}>
      {selectedOrgs.map((orgId) => (
        <OrganizationProvider
          key={orgId}
          organizationId={orgId}
        >
          <OrganizationCard />
        </OrganizationProvider>
      ))}
    </div>
  );
}

function OrganizationCard() {
  const [organizationData] = useAtom(organizationDataAtom);
  const [members] = useAtom(membersAtom);

  return (
    <div className='org-card'>
      <h3>{organizationData?.name}</h3>
      <p>Members: {members.length}</p>
    </div>
  );
}

このように、Provider パターンを活用することで、atom の基本機能を大幅に拡張し、より柔軟で実用的な状態管理を実現することができます。次に、カスタム Provider の作成方法について詳しく見ていきましょう。

カスタム Provider の作成と活用

独自の Provider コンポーネント作成

Jotai の基本的な Provider をベースに、アプリケーション固有のニーズに合わせたカスタム Provider を作成することで、より使いやすく保守しやすい状態管理を実現できます。

typescriptimport { Provider, atom, useAtom, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// アプリケーション固有のatom定義
const appStateAtom = atom({
  isInitialized: false,
  version: '1.0.0',
  environment: 'development' as
    | 'development'
    | 'staging'
    | 'production',
});

const userSessionAtom = atomWithStorage<{
  userId?: string;
  sessionId?: string;
  lastActivity?: number;
} | null>('user-session', null);

const featureFlagsAtom = atom<Record<string, boolean>>({});

// カスタムProvider型定義
interface AppProviderProps {
  children: React.ReactNode;
  config: {
    environment: 'development' | 'staging' | 'production';
    apiEndpoint: string;
    features?: Record<string, boolean>;
  };
  initialSession?: {
    userId: string;
    sessionId: string;
  };
}

export function AppProvider({
  children,
  config,
  initialSession,
}: AppProviderProps) {
  const [isReady, setIsReady] = useState(false);

  const initialValues: [any, any][] = [
    [
      appStateAtom,
      {
        isInitialized: true,
        version: '1.0.0',
        environment: config.environment,
      },
    ],
    [featureFlagsAtom, config.features || {}],
  ];

  if (initialSession) {
    initialValues.push([
      userSessionAtom,
      {
        ...initialSession,
        lastActivity: Date.now(),
      },
    ]);
  }

  useEffect(() => {
    // 初期化処理
    async function initialize() {
      try {
        // 環境固有の初期化処理
        if (config.environment === 'production') {
          await initializeAnalytics();
        }

        // 機能フラグの取得
        if (!config.features) {
          const features = await fetchFeatureFlags(
            config.environment
          );
          // 動的にfeatureFlagsを更新する処理
        }

        setIsReady(true);
      } catch (error) {
        console.error('App initialization failed:', error);
        setIsReady(true); // エラーでも継続
      }
    }

    initialize();
  }, [config]);

  if (!isReady) {
    return (
      <div className='app-loading'>
        <div>アプリケーションを初期化中...</div>
      </div>
    );
  }

  return (
    <Provider initialValues={initialValues}>
      <AppErrorBoundary>
        <AppInitializer>{children}</AppInitializer>
      </AppErrorBoundary>
    </Provider>
  );
}

// アプリケーション初期化コンポーネント
function AppInitializer({
  children,
}: {
  children: React.ReactNode;
}) {
  const [appState] = useAtom(appStateAtom);
  const [session, setSession] = useAtom(userSessionAtom);

  useEffect(() => {
    // セッションの自動更新
    const interval = setInterval(() => {
      if (session) {
        setSession((prev) =>
          prev
            ? {
                ...prev,
                lastActivity: Date.now(),
              }
            : null
        );
      }
    }, 60000); // 1分ごと

    return () => clearInterval(interval);
  }, [session, setSession]);

  return <>{children}</>;
}

// エラー境界コンポーネント
class AppErrorBoundary extends React.Component<
  { children: React.ReactNode },
  { hasError: boolean; error?: Error }
> {
  constructor(props: { children: React.ReactNode }) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(
    error: Error,
    errorInfo: React.ErrorInfo
  ) {
    console.error('App Error:', error, errorInfo);
    // エラー報告サービスに送信
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className='error-fallback'>
          <h2>アプリケーションエラーが発生しました</h2>
          <button
            onClick={() =>
              this.setState({ hasError: false })
            }
          >
            再試行
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Provider Hooks パターンの実装

カスタム Provider と組み合わせて使用する専用のフックを作成することで、より使いやすい API を提供できます。

typescript// カスタムフックの実装
export function useAppState() {
  const [appState] = useAtom(appStateAtom);
  return appState;
}

export function useFeatureFlag(flagName: string): boolean {
  const [features] = useAtom(featureFlagsAtom);
  return features[flagName] ?? false;
}

export function useUserSession() {
  const [session, setSession] = useAtom(userSessionAtom);

  const login = useCallback(
    async (userId: string, sessionId: string) => {
      setSession({
        userId,
        sessionId,
        lastActivity: Date.now(),
      });
    },
    [setSession]
  );

  const logout = useCallback(() => {
    setSession(null);
  }, [setSession]);

  const updateActivity = useCallback(() => {
    setSession((prev) =>
      prev
        ? {
            ...prev,
            lastActivity: Date.now(),
          }
        : null
    );
  }, [setSession]);

  return {
    session,
    isLoggedIn: !!session,
    login,
    logout,
    updateActivity,
  };
}

// コンテキスト風のフック
export function useAppContext() {
  const appState = useAppState();
  const userSession = useUserSession();
  const getFeatureFlag = (flagName: string) => {
    const [features] = useAtom(featureFlagsAtom);
    return features[flagName] ?? false;
  };

  return {
    ...appState,
    ...userSession,
    getFeatureFlag,
  };
}

// 使用例
function FeatureComponent() {
  const { isLoggedIn } = useUserSession();
  const isNewFeatureEnabled = useFeatureFlag('new-feature');
  const appState = useAppState();

  if (!appState.isInitialized) {
    return <div>初期化中...</div>;
  }

  return (
    <div>
      {isLoggedIn && isNewFeatureEnabled && (
        <NewFeatureButton />
      )}
    </div>
  );
}

型安全なカスタム Provider 設計

TypeScript を活用して、型安全で使いやすいカスタム Provider を設計しましょう。

typescript// 型定義
interface StrictAppConfig {
  readonly environment:
    | 'development'
    | 'staging'
    | 'production';
  readonly apiEndpoint: string;
  readonly version: string;
  readonly features: Readonly<Record<string, boolean>>;
}

interface UserProfile {
  readonly id: string;
  readonly name: string;
  readonly email: string;
  readonly roles: readonly string[];
}

// 型安全なatom定義
const strictAppConfigAtom = atom<StrictAppConfig>({
  environment: 'development',
  apiEndpoint: '',
  version: '1.0.0',
  features: {},
});

const userProfileAtom = atom<UserProfile | null>(null);

// Provider Props の厳密な型定義
interface StrictAppProviderProps {
  readonly children: React.ReactNode;
  readonly config: StrictAppConfig;
  readonly initialUser?: UserProfile;
}

// 型安全なProvider実装
export function StrictAppProvider({
  children,
  config,
  initialUser,
}: StrictAppProviderProps) {
  // 型チェック
  const validatedConfig = useMemo(() => {
    if (!config.apiEndpoint) {
      throw new Error('API endpoint is required');
    }
    if (!config.version.match(/^\d+\.\d+\.\d+$/)) {
      throw new Error('Invalid version format');
    }
    return config;
  }, [config]);

  return (
    <Provider
      initialValues={[
        [strictAppConfigAtom, validatedConfig],
        ...(initialUser
          ? [[userProfileAtom, initialUser]]
          : []),
      ]}
    >
      {children}
    </Provider>
  );
}

// 型安全なフック
export function useStrictAppConfig(): StrictAppConfig {
  const [config] = useAtom(strictAppConfigAtom);
  return config;
}

export function useUserProfile(): {
  readonly user: UserProfile | null;
  readonly setUser: (user: UserProfile | null) => void;
  readonly hasRole: (role: string) => boolean;
} {
  const [user, setUser] = useAtom(userProfileAtom);

  const hasRole = useCallback(
    (role: string): boolean => {
      return user?.roles.includes(role) ?? false;
    },
    [user]
  );

  return { user, setUser, hasRole };
}

// 型安全な使用例
function TypeSafeComponent() {
  const config = useStrictAppConfig();
  const { user, hasRole } = useUserProfile();

  // TypeScriptによる完全な型チェック
  const isAdmin: boolean = hasRole('admin');
  const apiUrl: string = config.apiEndpoint;
  const isDevelopment: boolean =
    config.environment === 'development';

  return (
    <div>
      <p>Environment: {config.environment}</p>
      <p>Version: {config.version}</p>
      {user && <p>Welcome, {user.name}!</p>}
      {isAdmin && <AdminPanel />}
    </div>
  );
}

SSR/SSG での Provider 活用

Next.js での Provider 設定

Next.js アプリケーションでの Provider 設定は、サーバーサイドレンダリングとクライアントサイドの状態同期を考慮する必要があります。

typescript// _app.tsx での Provider 設定
import type { AppProps } from 'next/app';
import { Provider } from 'jotai';
import { userAtom, appConfigAtom } from '../atoms';

interface MyAppProps extends AppProps {
  initialUser?: User;
  config: AppConfig;
}

function MyApp({
  Component,
  pageProps,
  initialUser,
  config,
}: MyAppProps) {
  const initialValues: [any, any][] = [
    [appConfigAtom, config],
  ];

  if (initialUser) {
    initialValues.push([userAtom, initialUser]);
  }

  return (
    <Provider initialValues={initialValues}>
      <Component {...pageProps} />
    </Provider>
  );
}

// getInitialProps でサーバーサイドデータを取得
MyApp.getInitialProps = async (appContext) => {
  const { ctx } = appContext;

  // サーバーサイドでの初期データ取得
  let initialUser: User | undefined;

  if (ctx.req) {
    // サーバーサイド処理
    const token = extractTokenFromRequest(ctx.req);
    if (token) {
      try {
        initialUser = await validateTokenServer(token);
      } catch (error) {
        // トークン無効の場合はログイン状態をクリア
        console.warn('Invalid token:', error);
      }
    }
  }

  const config: AppConfig = {
    environment: process.env.NODE_ENV as any,
    apiEndpoint: process.env.NEXT_PUBLIC_API_ENDPOINT!,
    version: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
  };

  return {
    initialUser,
    config,
  };
};

export default MyApp;

サーバーサイド初期化の実装

typescript// pages/dashboard.tsx - SSGでの実装例
import { GetStaticProps } from 'next';
import { Provider } from 'jotai';
import {
  dashboardDataAtom,
  userPreferencesAtom,
} from '../atoms';

interface DashboardPageProps {
  initialDashboardData: DashboardData;
  defaultPreferences: UserPreferences;
}

export default function DashboardPage({
  initialDashboardData,
  defaultPreferences,
}: DashboardPageProps) {
  return (
    <Provider
      initialValues={[
        [dashboardDataAtom, initialDashboardData],
        [userPreferencesAtom, defaultPreferences],
      ]}
    >
      <DashboardLayout>
        <DashboardContent />
      </DashboardLayout>
    </Provider>
  );
}

export const getStaticProps: GetStaticProps = async () => {
  // ビルド時にデータを取得
  const [dashboardData, preferences] = await Promise.all([
    fetchDefaultDashboardData(),
    fetchDefaultUserPreferences(),
  ]);

  return {
    props: {
      initialDashboardData: dashboardData,
      defaultPreferences: preferences,
    },
    revalidate: 3600, // 1時間ごとに再生成
  };
};

// pages/user/[id].tsx - SSRでの実装例
import { GetServerSideProps } from 'next';

interface UserPageProps {
  user: User;
  userStats: UserStats;
}

export default function UserPage({
  user,
  userStats,
}: UserPageProps) {
  return (
    <Provider
      initialValues={[
        [userAtom, user],
        [userStatsAtom, userStats],
      ]}
    >
      <UserProfile />
      <UserStatistics />
    </Provider>
  );
}

export const getServerSideProps: GetServerSideProps =
  async (context) => {
    const { id } = context.params!;

    try {
      const [user, userStats] = await Promise.all([
        fetchUser(id as string),
        fetchUserStats(id as string),
      ]);

      if (!user) {
        return { notFound: true };
      }

      return {
        props: {
          user,
          userStats,
        },
      };
    } catch (error) {
      console.error('Failed to fetch user data:', error);
      return { notFound: true };
    }
  };

ハイドレーション対応とベストプラクティス

typescript// utils/hydration.ts
import { atom } from 'jotai';

// ハイドレーション状態の管理
const isHydratedAtom = atom(false);

export function useHydration() {
  const [isHydrated, setIsHydrated] =
    useAtom(isHydratedAtom);

  useEffect(() => {
    setIsHydrated(true);
  }, [setIsHydrated]);

  return isHydrated;
}

// SSR安全なコンポーネント
export function SSRSafeComponent({
  children,
  fallback = null,
}: {
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const isHydrated = useHydration();

  if (!isHydrated) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

// 使用例
function ClientOnlyFeature() {
  const [userPreferences] = useAtom(userPreferencesAtom);

  return (
    <SSRSafeComponent
      fallback={<div>Loading preferences...</div>}
    >
      <div>
        Theme: {userPreferences.theme}
        {/* クライアントサイドでのみ表示される内容 */}
        <LocalStorageManager />
        <BrowserSpecificFeatures />
      </div>
    </SSRSafeComponent>
  );
}

// App Router での Provider 設定(Next.js 13+)
// app/providers.tsx
('use client');

import { Provider } from 'jotai';
import { ReactNode } from 'react';

interface ProvidersProps {
  children: ReactNode;
  initialValues?: [any, any][];
}

export function Providers({
  children,
  initialValues = [],
}: ProvidersProps) {
  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Provider のスコーピングとパフォーマンス最適化

Provider スコープの設計原則

効果的な Provider スコープの設計は、アプリケーションのパフォーマンスと保守性に大きく影響します。

typescript// 設計原則1: 機能単位でのスコープ分離
function FeatureScopedProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider>
      <FeatureSpecificProvider>
        {children}
      </FeatureSpecificProvider>
    </Provider>
  );
}

// 設計原則2: ライフサイクルベースの分離
function PageScopedProvider({
  children,
  pageId,
}: {
  children: React.ReactNode;
  pageId: string;
}) {
  return <Provider key={pageId}>{children}</Provider>;
}

// 設計原則3: パフォーマンスクリティカルな部分の最適化
const heavyComputationAtom = atom((get) => {
  // 重い計算処理
  return performHeavyComputation(get(inputDataAtom));
});

function OptimizedProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <Provider>
      <Suspense fallback={<div>Computing...</div>}>
        {children}
      </Suspense>
    </Provider>
  );
}

メモリリークの防止策

typescript// メモリリーク防止のためのクリーンアップパターン
function CleanupProvider({
  children,
  instanceId,
}: {
  children: React.ReactNode;
  instanceId: string;
}) {
  const cleanupRef = useRef<(() => void)[]>([]);

  useEffect(() => {
    return () => {
      // Provider のアンマウント時にクリーンアップ
      cleanupRef.current.forEach((cleanup) => cleanup());
      cleanupRef.current = [];
    };
  }, []);

  const registerCleanup = useCallback(
    (cleanup: () => void) => {
      cleanupRef.current.push(cleanup);
    },
    []
  );

  return (
    <Provider key={instanceId}>
      <CleanupContext.Provider value={{ registerCleanup }}>
        {children}
      </CleanupContext.Provider>
    </Provider>
  );
}

// 自動クリーンアップを行うカスタムフック
function useAutoCleanup<T>(
  atom: Atom<T>,
  initialValue: T
): [T, (value: T) => void] {
  const [value, setValue] = useAtom(atom);
  const { registerCleanup } = useContext(CleanupContext);

  useEffect(() => {
    registerCleanup(() => {
      setValue(initialValue);
    });
  }, [registerCleanup, setValue, initialValue]);

  return [value, setValue];
}

不要な再レンダリングの回避

typescript// 再レンダリング最適化のパターン
const memoizedDerivedAtom = atom((get) => {
  const data = get(sourceDataAtom);
  const filter = get(filterAtom);

  // 重い計算結果をメモ化
  return useMemo(() => {
    return data.filter((item) => filter.test(item));
  }, [data, filter]);
});

// Provider レベルでの最適化
function OptimizedProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const memoizedChildren = useMemo(
    () => children,
    [children]
  );

  return <Provider>{memoizedChildren}</Provider>;
}

// 分離された更新パターン
const readOnlyDataAtom = atom((get) => get(sourceDataAtom));
const writeOnlyUpdateAtom = atom(
  null,
  (get, set, update: any) => {
    set(sourceDataAtom, update);
  }
);

function SeparatedConcernComponent() {
  const data = useAtomValue(readOnlyDataAtom);
  const updateData = useSetAtom(writeOnlyUpdateAtom);

  // 読み取り専用コンポーネントは更新処理で再レンダリングされない
  return (
    <div>
      <DisplayOnlySection data={data} />
      <UpdateButton onUpdate={updateData} />
    </div>
  );
}

実践的な Provider 設計パターン

マルチテナント対応の Provider 設計

typescriptinterface TenantConfig {
  tenantId: string;
  name: string;
  theme: ThemeConfig;
  features: FeatureFlags;
  apiEndpoint: string;
}

const tenantConfigAtom = atom<TenantConfig | null>(null);
const tenantDataAtom = atom<TenantData>({});

function TenantProvider({
  children,
  tenantId,
}: {
  children: React.ReactNode;
  tenantId: string;
}) {
  const [tenantConfig, setTenantConfig] =
    useState<TenantConfig | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function loadTenantConfig() {
      setIsLoading(true);
      try {
        const config = await fetchTenantConfig(tenantId);
        setTenantConfig(config);
      } catch (error) {
        console.error(
          'Failed to load tenant config:',
          error
        );
        // フォールバック設定
        setTenantConfig(getDefaultTenantConfig(tenantId));
      } finally {
        setIsLoading(false);
      }
    }

    loadTenantConfig();
  }, [tenantId]);

  if (isLoading) {
    return <TenantLoadingScreen />;
  }

  if (!tenantConfig) {
    return <TenantErrorScreen tenantId={tenantId} />;
  }

  return (
    <Provider
      key={tenantId}
      initialValues={[
        [tenantConfigAtom, tenantConfig],
        [tenantDataAtom, {}],
      ]}
    >
      <TenantThemeProvider theme={tenantConfig.theme}>
        <TenantFeatureGate features={tenantConfig.features}>
          {children}
        </TenantFeatureGate>
      </TenantThemeProvider>
    </Provider>
  );
}

// テナント切り替え対応
function MultiTenantApp() {
  const [currentTenant, setCurrentTenant] =
    useState('tenant-1');

  return (
    <div>
      <TenantSelector
        value={currentTenant}
        onChange={setCurrentTenant}
      />
      <TenantProvider tenantId={currentTenant}>
        <AppContent />
      </TenantProvider>
    </div>
  );
}

A/B テスト対応の Provider 構成

typescriptinterface ExperimentConfig {
  experimentId: string;
  variant: 'A' | 'B';
  userId: string;
  features: Record<string, boolean>;
}

const experimentAtom = atom<ExperimentConfig | null>(null);
const abTestResultsAtom = atom<Record<string, any>>({});

function ABTestProvider({
  children,
  userId,
}: {
  children: React.ReactNode;
  userId: string;
}) {
  const [experiment, setExperiment] =
    useState<ExperimentConfig | null>(null);

  useEffect(() => {
    async function assignExperiment() {
      try {
        const assignment = await getExperimentAssignment(
          userId
        );
        setExperiment(assignment);

        // A/Bテスト開始をトラッキング
        trackEvent('experiment_started', {
          experimentId: assignment.experimentId,
          variant: assignment.variant,
          userId,
        });
      } catch (error) {
        console.error(
          'Failed to assign experiment:',
          error
        );
        // デフォルト設定
        setExperiment({
          experimentId: 'default',
          variant: 'A',
          userId,
          features: {},
        });
      }
    }

    assignExperiment();
  }, [userId]);

  if (!experiment) {
    return <div>Preparing experiment...</div>;
  }

  return (
    <Provider
      initialValues={[
        [experimentAtom, experiment],
        [abTestResultsAtom, {}],
      ]}
    >
      <ExperimentTracker>{children}</ExperimentTracker>
    </Provider>
  );
}

// A/Bテスト用のフック
function useExperiment() {
  const [experiment] = useAtom(experimentAtom);
  const [, setResults] = useAtom(abTestResultsAtom);

  const trackConversion = useCallback(
    (conversionType: string, value?: number) => {
      if (!experiment) return;

      const result = {
        experimentId: experiment.experimentId,
        variant: experiment.variant,
        userId: experiment.userId,
        conversionType,
        value,
        timestamp: Date.now(),
      };

      setResults((prev) => ({
        ...prev,
        [conversionType]: result,
      }));

      // 外部システムにも送信
      trackConversionEvent(result);
    },
    [experiment, setResults]
  );

  return {
    variant: experiment?.variant || 'A',
    isVariantB: experiment?.variant === 'B',
    hasFeature: (feature: string) =>
      experiment?.features[feature] ?? false,
    trackConversion,
  };
}

// A/Bテストコンポーネントの例
function ABTestButton() {
  const { variant, trackConversion } = useExperiment();

  const handleClick = () => {
    trackConversion('button_click');
    // その他の処理...
  };

  return (
    <button
      onClick={handleClick}
      className={
        variant === 'B'
          ? 'button-variant-b'
          : 'button-variant-a'
      }
    >
      {variant === 'B' ? '新しいボタン' : '従来のボタン'}
    </button>
  );
}

認証・権限管理を組み込んだ Provider

typescriptinterface AuthUser {
  id: string;
  name: string;
  email: string;
  roles: string[];
  permissions: string[];
}

const authUserAtom = atom<AuthUser | null>(null);
const authTokenAtom = atom<string | null>(null);
const permissionsAtom = atom<Set<string>>(new Set());

function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [authState, setAuthState] = useState<{
    user: AuthUser | null;
    token: string | null;
    isLoading: boolean;
  }>({
    user: null,
    token: null,
    isLoading: true,
  });

  useEffect(() => {
    async function restoreAuth() {
      try {
        const token = localStorage.getItem('auth_token');
        if (token) {
          const user = await validateToken(token);
          setAuthState({
            user,
            token,
            isLoading: false,
          });
        } else {
          setAuthState({
            user: null,
            token: null,
            isLoading: false,
          });
        }
      } catch (error) {
        console.error('Auth restoration failed:', error);
        localStorage.removeItem('auth_token');
        setAuthState({
          user: null,
          token: null,
          isLoading: false,
        });
      }
    }

    restoreAuth();
  }, []);

  const initialValues: [any, any][] = [
    [authUserAtom, authState.user],
    [authTokenAtom, authState.token],
    [
      permissionsAtom,
      new Set(authState.user?.permissions || []),
    ],
  ];

  if (authState.isLoading) {
    return <AuthLoadingScreen />;
  }

  return (
    <Provider initialValues={initialValues}>
      <AuthManager>{children}</AuthManager>
    </Provider>
  );
}

// 認証管理コンポーネント
function AuthManager({
  children,
}: {
  children: React.ReactNode;
}) {
  const [user, setUser] = useAtom(authUserAtom);
  const [token, setToken] = useAtom(authTokenAtom);
  const [permissions, setPermissions] =
    useAtom(permissionsAtom);

  const login = useCallback(
    async (credentials: LoginCredentials) => {
      try {
        const response = await loginAPI(credentials);
        setUser(response.user);
        setToken(response.token);
        setPermissions(new Set(response.user.permissions));

        localStorage.setItem('auth_token', response.token);
      } catch (error) {
        throw new Error('Login failed');
      }
    },
    [setUser, setToken, setPermissions]
  );

  const logout = useCallback(() => {
    setUser(null);
    setToken(null);
    setPermissions(new Set());
    localStorage.removeItem('auth_token');
  }, [setUser, setToken, setPermissions]);

  return (
    <AuthContext.Provider value={{ login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// 権限チェック用のフックとコンポーネント
function usePermissions() {
  const [permissions] = useAtom(permissionsAtom);

  const hasPermission = useCallback(
    (permission: string) => {
      return permissions.has(permission);
    },
    [permissions]
  );

  const hasAnyPermission = useCallback(
    (requiredPermissions: string[]) => {
      return requiredPermissions.some((p) =>
        permissions.has(p)
      );
    },
    [permissions]
  );

  const hasAllPermissions = useCallback(
    (requiredPermissions: string[]) => {
      return requiredPermissions.every((p) =>
        permissions.has(p)
      );
    },
    [permissions]
  );

  return {
    hasPermission,
    hasAnyPermission,
    hasAllPermissions,
  };
}

function PermissionGate({
  permission,
  children,
  fallback,
}: {
  permission: string;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const { hasPermission } = usePermissions();

  if (!hasPermission(permission)) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

// 使用例
function AdminPanel() {
  return (
    <PermissionGate
      permission='admin.access'
      fallback={<div>管理者権限が必要です</div>}
    >
      <div>
        <h2>管理者パネル</h2>
        <PermissionGate permission='admin.users'>
          <UserManagement />
        </PermissionGate>
        <PermissionGate permission='admin.settings'>
          <SystemSettings />
        </PermissionGate>
      </div>
    </PermissionGate>
  );
}

トラブルシューティングとデバッグ

よくある Provider エラーと解決法

エラー 1: Provider の値が反映されない

typescript// 問題のあるコード
function BrokenProvider() {
  const [initialValue, setInitialValue] = useState(null);

  useEffect(() => {
    fetchData().then(setInitialValue);
  }, []);

  // initialValue が null の間に Provider が作成される
  return (
    <Provider initialValues={[[dataAtom, initialValue]]}>
      <Component />
    </Provider>
  );
}

// 修正されたコード
function FixedProvider() {
  const [initialValue, setInitialValue] = useState(null);
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    fetchData().then((value) => {
      setInitialValue(value);
      setIsReady(true);
    });
  }, []);

  if (!isReady) {
    return <div>Loading...</div>;
  }

  return (
    <Provider initialValues={[[dataAtom, initialValue]]}>
      <Component />
    </Provider>
  );
}

エラー 2: Provider の重複による意図しない動作

typescript// 問題のあるコード
function NestedProviders() {
  return (
    <Provider initialValues={[[countAtom, 0]]}>
      <Provider initialValues={[[countAtom, 10]]}>
        {' '}
        {/* 内側が優先される */}
        <Counter /> {/* count は 10 から開始 */}
      </Provider>
    </Provider>
  );
}

// 解決策1: 明示的な Provider 分離
function SeparatedProviders() {
  return (
    <Provider initialValues={[[globalCountAtom, 0]]}>
      <GlobalCounter />
      <Provider initialValues={[[localCountAtom, 10]]}>
        <LocalCounter />
      </Provider>
    </Provider>
  );
}

// 解決策2: 条件付き Provider
function ConditionalProvider({
  useLocal,
  children,
}: {
  useLocal: boolean;
  children: React.ReactNode;
}) {
  if (useLocal) {
    return (
      <Provider initialValues={[[countAtom, 10]]}>
        {children}
      </Provider>
    );
  }

  return <>{children}</>;
}

Provider のデバッグ手法

typescript// デバッグ用の Provider ラッパー
function DebugProvider({
  children,
  name,
  initialValues,
}: {
  children: React.ReactNode;
  name: string;
  initialValues?: [any, any][];
}) {
  useEffect(() => {
    console.log(
      `Provider ${name} mounted with initial values:`,
      initialValues
    );

    return () => {
      console.log(`Provider ${name} unmounted`);
    };
  }, [name, initialValues]);

  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

// atom の変更を追跡するフック
function useAtomDebugger<T>(
  atom: Atom<T>,
  atomName: string
): [T, (value: T) => void] {
  const [value, setValue] = useAtom(atom);

  useEffect(() => {
    console.log(`Atom ${atomName} changed:`, value);
  }, [value, atomName]);

  const debugSetValue = useCallback(
    (newValue: T) => {
      console.log(
        `Setting ${atomName} from`,
        value,
        'to',
        newValue
      );
      setValue(newValue);
    },
    [value, setValue, atomName]
  );

  return [value, debugSetValue];
}

// Provider チェーンの可視化
function ProviderChainDebugger() {
  const store = useStore();

  useEffect(() => {
    console.log('Current store state:', store);
  }, [store]);

  return null;
}

DevTools を使った Provider 状態確認

typescript// Jotai DevTools の統合
import { DevTools } from 'jotai-devtools';

function AppWithDevTools() {
  return (
    <Provider>
      <DevTools />
      <App />
    </Provider>
  );
}

// カスタム DevTools パネル
function CustomDevToolsPanel() {
  const [isOpen, setIsOpen] = useState(false);
  const [debugData, setDebugData] = useState<any>({});

  const collectDebugData = useCallback(() => {
    // 現在のatomの状態を収集
    const data = {
      providers: getProviderCount(),
      atoms: getAtomStates(),
      timestamp: Date.now(),
    };
    setDebugData(data);
  }, []);

  if (process.env.NODE_ENV === 'production') {
    return null;
  }

  return (
    <div className='devtools-panel'>
      <button onClick={() => setIsOpen(!isOpen)}>
        Debug Panel
      </button>
      {isOpen && (
        <div className='debug-content'>
          <button onClick={collectDebugData}>
            Refresh Data
          </button>
          <pre>{JSON.stringify(debugData, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

まとめ

Jotai の Provider パターンは、React アプリケーションの状態管理をより柔軟で実用的なものにする強力な仕組みです。本記事では、基本的な概念から実践的な活用方法まで幅広く解説してきました。

Provider パターンの主要なメリット

Provider を活用することで、以下の利点を得ることができます:

  • スコープ化された状態管理: 機能やページ単位での状態の分離
  • 動的な初期値設定: サーバーサイドデータやユーザー設定の反映
  • テスト時の状態隔離: 完全に独立したテスト環境の構築
  • マルチテナント対応: 組織やユーザー別の状態管理
  • SSR/SSG 対応: サーバーサイドレンダリングとの完全な統合

実践的な設計指針

効果的な Provider 設計のために、以下の原則を心がけましょう:

  1. 機能単位での分離: 関連する atom をグループ化し、適切なスコープを設定
  2. 型安全性の確保: TypeScript を活用した堅牢な型定義
  3. パフォーマンスの最適化: 不要な再レンダリングの回避とメモリ効率
  4. エラーハンドリング: 適切なフォールバック処理とエラー境界の実装

今後の発展性

Provider パターンをマスターすることで、React アプリケーションの設計能力が大幅に向上します。特に大規模なプロダクション環境では、Provider の適切な活用が開発効率と保守性の向上に直結するでしょう。

継続的な学習と実践を通じて、より洗練された状態管理アーキテクチャを構築し、ユーザーエクスペリエンスの向上と開発チームの生産性向上を同時に実現してください。Provider パターンは、Jotai の真の力を引き出すための重要な鍵となることでしょう。

関連リンク