Jotaiでグローバル状態とローカル状態を共存させる方法

スポンサーリンク

はじめに

Reactで状態管理を行う際、「どこまで状態を共有するか」というスコープ設計は避けて通れません。
アプリ全体で共通するデータ(ログインユーザーやテーマ設定など)は「グローバル状態」として扱いたい一方で、
ページやコンポーネント単位で完結する「ローカル状態」も存在します。

Jotaiは、軽量で柔軟な状態管理ライブラリとして人気ですが、<Provider>を使ってスコープを分けると、
同じAtomでもスコープごとに独立した状態を持つようになります。
これは便利な一方で、「ローカルスコープ内でグローバル状態を参照・更新したい」という場面で混乱を招くことがあります。

本記事では、
<Provider>で囲まれたローカルスコープの中から、意図的にグローバルスコープのAtomを参照・更新する方法
を中心に、コード例とともに解説します。

Jotaiのスコープの仕組みを理解する

まずは、Jotaiにおけるスコープの基本を理解しておきましょう。

Jotaiでは、「ストア (store)」という単位でAtomの状態を管理しています。
アプリを<Provider>で囲むと、その内部で独立したストアが生成され、
その範囲内のコンポーネントはすべて同じストアを共有します。

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

const counterAtom = atom(0);

const Counter = () => {
  const [count, setCount] = useAtom(counterAtom);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
};

// ルートでの利用
export const App = () => (
  <>
    {/* それぞれのProviderでストアが独立する */}
    <Provider>
      <Counter />
    </Provider>

    <Provider>
      <Counter />
    </Provider>
  </>
);

上記のように<Provider>を2つ並べた場合、
両方のCounterは同じcounterAtomを使っているにも関わらず、状態が完全に独立します。
つまり、1つのボタンをクリックしても、もう一方には影響しません。

これはJotaiの柔軟な特徴ですが、逆に言えば「スコープをまたいで状態共有したいとき」には工夫が必要です。

課題:ローカルスコープ内でグローバル状態を参照したい

たとえば以下のようなユースケースを考えてみましょう。

  • ページ内では独自のカウンターやフォームなどのローカル状態を持ちたい
  • ただし、ログイン中のユーザー情報やテーマなどはアプリ全体で共有したい

このとき、ページをローカルスコープでラップしてしまうと、
その内部でuseAtom(userAtom)を呼び出しても、グローバルのuserAtomにはアクセスできません。

// NG例:ローカルProvider内ではグローバルAtomに届かない
<Provider>
  <PageComponent />
</Provider>

これを解決するには、「グローバルストア」を明示的に作成し、
useAtomなどのフックにそのストアを指定して呼び出す必要があります。

解決策:グローバルストアを明示的に作成・指定する

Jotaiのフック(useAtom, useAtomValue, useSetAtom)は、第二引数にオプションを受け取ります。
このオプションに { store: globalStore } のようにストアを明示的に渡すことで、
どのストアを操作するかを制御できます。

const [user, setUser] = useAtom(userAtom, { store: globalStore });

この方法を使えば、ローカルスコープ内からでもグローバルストアを参照できるようになります。
つまり、Providerで囲まれた「壁」を越えて、状態を共有することが可能です。

実装ステップ:グローバルとローカルを共存させる

ここからは、実際の実装手順を3ステップで解説します。

Step 1. グローバルストアの作成 (src/store.ts)

まずはアプリ全体で共有するグローバルストアを作成します。
createStoreを使ってインスタンスを生成し、エクスポートしておきます。

// src/store.ts
import { createStore } from 'jotai';

// グローバルスコープとして利用するストアを作成
export const globalStore = createStore();

Atomの定義は別ファイルに分けておくと整理しやすいです。

// src/atoms.ts
import { atom } from 'jotai';

// グローバルで使うAtom
export const userAtom = atom<{ name: string } | null>(null);

// ページ単位で使うローカルAtom
export const pageCounterAtom = atom(0);

Step 2. アプリ全体をグローバルストアで包む (src/main.tsx)

アプリのルート(通常はmain.tsxまたはApp.tsx)で、
<Provider>store={globalStore}を指定してアプリ全体を包みます。

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'jotai';
import { globalStore } from './store';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Provider store={globalStore}>
      <App />
    </Provider>
  </React.StrictMode>,
);

これで、グローバルスコープの基準が定義されました。

Step 3. ローカルスコープ内でグローバルAtomを参照する

次に、ローカルスコープを作成したいページで、
独自の<Provider>を使ってスコープを分離します。
その上で、グローバルなAtomを参照したい場合だけstoreを明示的に指定します。

// src/pages/MyScopedPage.tsx
import { Provider, useAtom } from 'jotai';
import { userAtom, pageCounterAtom } from '../atoms';
import { globalStore } from '../store';

const PageContent = () => {
  // ローカルAtom(スコープ内)
  const [count, setCount] = useAtom(pageCounterAtom);

  // グローバルAtom(スコープをまたぐ)
  const [globalUser, setGlobalUser] = useAtom(userAtom, { store: globalStore });

  return (
    <div>
      <h3>Page Content (Scoped)</h3>

      <h4>Local State</h4>
      <button onClick={() => setCount(c => c + 1)}>
        Page Counter: {count}
      </button>

      <hr />

      <h4>Global State (accessed from local scope)</h4>
      <p>Global User: {globalUser?.name || 'Not set'}</p>
      <button onClick={() => setGlobalUser({ name: 'Updated from Page' })}>
        Update Global User from Page
      </button>
    </div>
  );
};

// ページ全体をローカルスコープでラップ
export const MyScopedPage = () => {
  return (
    <Provider>
      <PageContent />
    </Provider>
  );
};

この構成により、

  • pageCounterAtom はページ内に閉じたローカル状態
  • userAtom はアプリ全体で共有されるグローバル状態

として機能します。

内部動作を補足すると、

  • useAtom(pageCounterAtom) → 一番近い<Provider>(このページ用)のストアを参照
  • useAtom(userAtom, { store: globalStore }) → 明示的に指定したグローバルストアを参照

のように処理されるため、ローカルなスコープとグローバルなスコープを意図的に使い分けることができます。

まとめ

以下のようにストアの範囲を適切に指定してAtomを呼び出すことで、ローカルな状態とグローバルな状態を分けて利用できます。

呼び出し方対象ストア用途
useAtom(atom)一番近いProviderのストアローカルスコープの状態を参照
useAtom(atom, { store: globalStore })明示的に指定したグローバルストアグローバルスコープの状態を参照

この方法を使うことで、

  • コンポーネントの再利用性を高めつつ、
  • グローバル状態も一貫して共有できる

という、非常に柔軟で拡張性の高い状態管理アーキテクチャを構築できます。

コメント

タイトルとURLをコピーしました