ReactのグローバルState管理|JotaiとZustandの使い方と使い分けを実践的に解説


はじめに

前回の記事ではTanStack Routerを使ってReactアプリにページ遷移を実装する方法を解説しました。複数のページが増えてくると「別のページ間でデータを共有したい」「ログイン状態をどこからでも参照したい」という場面が出てきます。今回はReactのグローバルState管理ライブラリとしてJotaiとZustandの使い方と使い分けを解説します。どちらもReact標準の useContext より手軽で強力なライブラリです。


1. グローバルStateが必要になる場面

1.1 propsのバケツリレーの問題

前回までの記事でpropsを使って親から子にデータを渡す方法を学びました。しかしコンポーネントの階層が深くなると「バケツリレー(Prop Drilling)」という問題が発生します。

tsx

// ❌ propsのバケツリレー(コンポーネントを経由するだけでpropsを渡す)
function App() {
    const [user, setUser] = useState<User | null>( null );
    return <Layout user={user} setUser={setUser} />;
}

function Layout( { user, setUser }: Props ) {
    return <Header user={user} setUser={setUser} />;
    //      ↑ Layoutはuserを直接使わないのにpropsで受け取る
}

function Header( { user, setUser }: Props ) {
    return <UserMenu user={user} setUser={setUser} />;
    //      ↑ Headerも直接使わない
}

function UserMenu( { user }: Props ) {
    return <span>{user?.name}</span>; // ← ここで初めて使う
}

1.2 グローバルStateが役立つ場面

状態の種類具体例
認証情報ログイン中のユーザー情報・トークン
UIの設定ダークモード・言語設定・サイドバーの開閉
カート・注文情報ECサイトのショッピングカート
通知・トースト成功・エラーメッセージの表示管理
APIのキャッシュ複数コンポーネントで共有するフェッチデータ

1.3 JotaiとZustandの比較

比較JotaiZustand
設計思想アトム単位で状態を管理する1つのStoreで状態をまとめる
学習コスト低(useState に近い感覚)低〜中
TypeScript対応✅ 優秀✅ 優秀
DevTools✅ 対応✅ 対応
向いているケース細かい単位で状態を分割したい機能ごとにStoreをまとめたい
バンドルサイズ小さい小さい

2. Jotaiの使い方

2.1 インストール

bash

npm install jotai

2.2 アトムの基本

Jotaiでは**アトム(atom)**という単位で状態を管理します。アトムはグローバルな useState のようなイメージです。

tsx

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

// アトムを定義する(初期値を渡す)
export const themeAtom = atom<'light' | 'dark'>( 'light' );

// 数値のアトム
export const countAtom = atom<number>( 0 );

// オブジェクトのアトム
export const userAtom = atom<User | null>( null );

// 配列のアトム
export const notificationsAtom = atom<Notification[]>( [] );

2.3 コンポーネントでアトムを使う

tsx

// src/components/ThemeToggle.tsx
import { useAtom } from 'jotai';
import { themeAtom } from '@/atoms/themeAtom';

function ThemeToggle() {
    // useAtomはuseStateと同じように[値, setter]を返す
    const [theme, setTheme] = useAtom( themeAtom );

    const toggle = () => {
        setTheme( prev => prev === 'light' ? 'dark' : 'light' );
        document.documentElement.setAttribute(
            'data-theme',
            theme === 'light' ? 'dark' : 'light'
        );
    };

    return (
        <button
            onClick={toggle}
            aria-label={`${theme === 'light' ? 'ダーク' : 'ライト'}モードに切り替える`}
            className="theme-toggle"
        >
            {theme === 'light' ? '🌙' : '☀️'}
        </button>
    );
}
// src/components/Header.tsx
import { useAtomValue } from 'jotai'; // 読み取り専用
import { themeAtom }   from '@/atoms/themeAtom';

function Header() {
    // useAtomValueは値だけを返す(setterが不要な場合に使う)
    const theme = useAtomValue( themeAtom );

    return (
        <header className={`site-header site-header--${theme}`}>
            {/* ... */}
        </header>
    );
}

💡 useAtomuseAtomValueuseSetAtom を使い分けましょう:

const [value, setValue] = useAtom( atom );     // 読み書きどちらもする
const value             = useAtomValue( atom ); // 読み取りだけする
const setValue          = useSetAtom( atom );   // 書き込みだけする(再レンダリングを最小化)

2.4 派生アトム(derivedAtom)

既存のアトムから計算した値を別のアトムとして定義できます。

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

interface CartItem {
    id:       number;
    name:     string;
    price:    number;
    quantity: number;
}

// ベースアトム
export const cartItemsAtom = atom<CartItem[]>( [] );

// 派生アトム(読み取り専用)
export const cartTotalAtom = atom<number>(
    ( get ) => {
        const items = get( cartItemsAtom );
        return items.reduce(
            ( total, item ) => total + item.price * item.quantity,
            0
        );
    }
);

export const cartCountAtom = atom<number>(
    ( get ) => get( cartItemsAtom ).reduce(
        ( count, item ) => count + item.quantity,
        0
    )
);
// src/components/CartSummary.tsx
import { useAtomValue } from 'jotai';
import { cartItemsAtom, cartTotalAtom, cartCountAtom } from '@/atoms/cartAtoms';

function CartSummary() {
    const items = useAtomValue( cartItemsAtom );
    const total = useAtomValue( cartTotalAtom );
    const count = useAtomValue( cartCountAtom );

    return (
        <div className="cart-summary">
            <p>合計{count}点</p>
            <p>¥{total.toLocaleString()}</p>
        </div>
    );
}

2.5 非同期アトム

// src/atoms/postsAtom.ts
import { atom } from 'jotai';
import type { Post } from '@/types/post';

// 非同期アトム(Promiseを返す関数を渡す)
export const postsAtom = atom<Promise<Post[]>>(
    async () => {
        const res = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=10' );
        if ( !res.ok ) throw new Error( 'Failed to fetch posts' );
        return res.json() as Promise<Post[]>;
    }
);
// src/components/PostList.tsx
import { Suspense }      from 'react';
import { useAtomValue }  from 'jotai';
import { postsAtom }     from '@/atoms/postsAtom';

// 非同期アトムはSuspenseと組み合わせて使う
function PostList() {
    return (
        <Suspense fallback={<PostListSkeleton />}>
            <PostListInner />
        </Suspense>
    );
}

function PostListInner() {
    // Suspenseが解決するまでここは実行されない
    const posts = useAtomValue( postsAtom );

    return (
        <ul>
            {posts.map( post => (
                <li key={post.id}>{post.title}</li>
            ) )}
        </ul>
    );
}

2.6 localStorageと連携するアトム

第五十六弾で解説した localStorage をJotaiのアトムと組み合わせる方法です。

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

// atomWithStorageを使うと自動的にlocalStorageに永続化される
export const themeAtom    = atomWithStorage<'light' | 'dark'>( 'pref_theme', 'light' );
export const languageAtom = atomWithStorage<string>( 'pref_language', 'ja' );

interface UserPreference {
    fontSize:       number;
    notifications:  boolean;
    compactMode:    boolean;
}

export const preferenceAtom = atomWithStorage<UserPreference>(
    'pref_user',
    {
        fontSize:      16,
        notifications: true,
        compactMode:   false,
    }
);

💡 atomWithStorage はJotaiの jotai/utils に含まれています: 読み書きのたびに自動的に localStorage と同期するため、第◯弾で作ったラッパー関数を書く必要がなくなります。


3. Zustandの使い方

3.1 インストール

bash

npm install zustand

3.2 Storeの基本

ZustandではStoreという単位で状態と操作をまとめて定義します。

tsx

// src/stores/useThemeStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // localStorageに永続化する

interface ThemeState {
    // 状態
    theme: 'light' | 'dark';

    // アクション(状態を変更する関数)
    setTheme:   ( theme: 'light' | 'dark' ) => void;
    toggleTheme: () => void;
    resetTheme:  () => void;
}

// persistミドルウェアでlocalStorageに自動保存する
export const useThemeStore = create<ThemeState>()(
    persist(
        ( set, get ) => ( {
            // 初期状態
            theme: 'light',

            // アクション
            setTheme: ( theme ) => {
                set( { theme } );
                document.documentElement.setAttribute( 'data-theme', theme );
            },

            toggleTheme: () => {
                const next = get().theme === 'light' ? 'dark' : 'light';
                get().setTheme( next );
            },

            resetTheme: () => {
                const prefersDark = window.matchMedia( '(prefers-color-scheme: dark)' ).matches;
                get().setTheme( prefersDark ? 'dark' : 'light' );
            },
        } ),
        {
            name: 'theme-store', // localStorageのキー名
        }
    )
);

3.3 コンポーネントでStoreを使う

tsx

// src/components/ThemeToggle.tsx
import { useThemeStore } from '@/stores/useThemeStore';

function ThemeToggle() {
    // 必要な値とアクションだけを取り出す(パフォーマンス最適化)
    const theme       = useThemeStore( state => state.theme );
    const toggleTheme = useThemeStore( state => state.toggleTheme );

    return (
        <button onClick={toggleTheme} className="theme-toggle">
            {theme === 'light' ? '🌙' : '☀️'}
        </button>
    );
}

// 複数の値をまとめて取り出す
function Header() {
    const { theme, setTheme, resetTheme } = useThemeStore();

    return (
        <header>
            <button onClick={() => setTheme( 'dark' )}>ダーク</button>
            <button onClick={() => setTheme( 'light' )}>ライト</button>
            <button onClick={resetTheme}>リセット</button>
        </header>
    );
}

3.4 より複雑なStoreの例(カート機能)

// src/stores/useCartStore.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

interface CartItem {
    id:       number;
    name:     string;
    price:    number;
    quantity: number;
    imageUrl: string;
}

interface CartState {
    // 状態
    items: CartItem[];

    // アクション
    addItem:    ( item: Omit<CartItem, 'quantity'> ) => void;
    removeItem: ( id: number ) => void;
    updateQty:  ( id: number, quantity: number ) => void;
    clearCart:  () => void;

    // 派生値(getterとして定義)
    totalPrice:  () => number;
    totalCount:  () => number;
    hasItem:     ( id: number ) => boolean;
}

export const useCartStore = create<CartState>()(
    devtools( // ReduxDevToolsで状態を確認できる
        persist(
            ( set, get ) => ( {
                items: [],

                addItem: ( item ) => {
                    const existing = get().items.find( i => i.id === item.id );

                    if ( existing ) {
                        // すでにカートにあれば数量を増やす
                        set( state => ( {
                            items: state.items.map( i =>
                                i.id === item.id
                                    ? { ...i, quantity: i.quantity + 1 }
                                    : i
                            ),
                        } ) );
                    } else {
                        // 新しいアイテムを追加する
                        set( state => ( {
                            items: [...state.items, { ...item, quantity: 1 }],
                        } ) );
                    }
                },

                removeItem: ( id ) => {
                    set( state => ( {
                        items: state.items.filter( i => i.id !== id ),
                    } ) );
                },

                updateQty: ( id, quantity ) => {
                    if ( quantity <= 0 ) {
                        get().removeItem( id );
                        return;
                    }
                    set( state => ( {
                        items: state.items.map( i =>
                            i.id === id ? { ...i, quantity } : i
                        ),
                    } ) );
                },

                clearCart: () => set( { items: [] } ),

                totalPrice: () =>
                    get().items.reduce(
                        ( total, item ) => total + item.price * item.quantity,
                        0
                    ),

                totalCount: () =>
                    get().items.reduce(
                        ( count, item ) => count + item.quantity,
                        0
                    ),

                hasItem: ( id ) =>
                    get().items.some( item => item.id === id ),
            } ),
            { name: 'cart-store' }
        )
    )
);
// src/components/ProductCard.tsx
import { useCartStore } from '@/stores/useCartStore';

interface Product {
    id:       number;
    name:     string;
    price:    number;
    imageUrl: string;
}

function ProductCard( { product }: { product: Product } ) {
    const addItem = useCartStore( state => state.addItem );
    const hasItem = useCartStore( state => state.hasItem );

    const inCart = hasItem( product.id );

    return (
        <article className="product-card">
            <img src={product.imageUrl} alt={product.name} />
            <h3>{product.name}</h3>
            <p>¥{product.price.toLocaleString()}</p>
            <button
                onClick={() => addItem( product )}
                disabled={inCart}
                className={`btn ${inCart ? 'btn--added' : 'btn--primary'}`}
            >
                {inCart ? '✓ カートに追加済み' : 'カートに追加'}
            </button>
        </article>
    );
}

4. 認証状態をグローバルで管理する

4.1 Zustandで認証Storeを作る

// src/stores/useAuthStore.ts
import { create }   from 'zustand';
import { persist }  from 'zustand/middleware';

interface User {
    id:    number;
    name:  string;
    email: string;
    role:  'admin' | 'editor' | 'viewer';
}

interface AuthState {
    user:      User | null;
    token:     string | null;
    isLoading: boolean;

    login:    ( email: string, password: string ) => Promise<void>;
    logout:   () => void;
    isLoggedIn: () => boolean;
    hasRole:  ( role: User['role'] ) => boolean;
}

export const useAuthStore = create<AuthState>()(
    persist(
        ( set, get ) => ( {
            user:      null,
            token:     null,
            isLoading: false,

            login: async ( email, password ) => {
                set( { isLoading: true } );
                try {
                    // 実際にはAPIを呼ぶ
                    const res = await fetch( '/api/auth/login', {
                        method:  'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body:    JSON.stringify( { email, password } ),
                    } );
                    if ( !res.ok ) throw new Error( 'ログインに失敗しました' );
                    const { user, token } = await res.json();
                    set( { user, token } );
                } finally {
                    set( { isLoading: false } );
                }
            },

            logout: () => {
                set( { user: null, token: null } );
            },

            isLoggedIn: () => get().user !== null,

            hasRole: ( role ) => get().user?.role === role,
        } ),
        {
            name:    'auth-store',
            partialize: ( state ) => ( {
                // tokenはlocalStorageに保存しない(セキュリティ上の理由)
                // userの基本情報だけ保存する
                user: state.user,
            } ),
        }
    )
);

⚠️ アクセストークンを localStorage に保存することはセキュリティリスクがあります: XSSでトークンが盗まれる可能性があります。本番環境ではトークンをHttpOnly Cookieで管理するのが推奨です。今回は学習用として簡略化しています。


5. JotaiとZustandの使い分け指針

5.1 どちらを選ぶか

Jotaiが向いているケース:
✅ UIの細かい状態(モーダルの開閉・各入力フィールドの状態)
✅ 状態をアトム単位で細かく分割したい
✅ 非同期データをSuspenseと組み合わせたい
✅ localStorageへの永続化をシンプルに書きたい(atomWithStorage)
✅ React的な書き方(useState感覚)が好き

Zustandが向いているケース:
✅ 認証・カートなど複数のデータと操作をまとめて管理したい
✅ Storeの操作ロジックをコンポーネント外に集約したい
✅ ReduxDevToolsでデバッグしたい
✅ React以外の場所(ユーティリティ関数など)からもStoreにアクセスしたい
✅ ミドルウェア(devtools・persist・immer)を使いたい

5.2 両方を組み合わせるのも有効

// UIの状態はJotai
import { atom } from 'jotai';
export const sidebarOpenAtom = atom( false );
export const activeTabAtom   = atom<'profile' | 'settings' | 'billing'>( 'profile' );

// ビジネスロジックはZustand
import { useAuthStore } from '@/stores/useAuthStore';
import { useCartStore } from '@/stores/useCartStore';

6. 実践チェックリスト

npm install jotai または npm install zustand でインストールできたか

Jotaiの場合:アトムを src/atoms/ ディレクトリに機能ごとに整理しているか

Zustandの場合:Storeを src/stores/ ディレクトリに機能ごとに整理しているか

読み取りだけのコンポーネントには useAtomValue または state => state.value のセレクターを使っているか

atomWithStorage または persist ミドルウェアでlocalStorageと同期できているか

機密情報(パスワード・完全なトークン)をlocalStorageに保存していないか

派生値(合計金額・件数など)を派生アトムまたはStoreのgetterで計算しているか

DevToolsで状態の変化を確認できているか


まとめ

今回はJotaiとZustandを使ったReactのグローバルState管理を解説しました。ポイントをまとめると:

  • propsのバケツリレーは階層が深くなると管理が難しくなり、グローバルStateで解決できる
  • JotaiはアトムがグローバルなuseStateのような感覚で直感的に使えてTypeScriptとの相性も良い
  • useAtomValueuseSetAtom を使い分けると不要な再レンダリングを防げる
  • 派生アトムで計算値(合計金額・件数など)を別のアトムとして表現できる
  • atomWithStorage でlocalStorageとの自動同期が1行で実現できる
  • Zustandはストアに状態と操作をまとめて定義してコンポーネント外からも使える
  • Zustandの persistdevtools ミドルウェアでlocalStorage永続化・デバッグが簡単になる
  • UIの細かい状態はJotai・ビジネスロジックはZustandという使い分けも有効

次の記事では、ReactアプリのデータフェッチングライブラリTanStack Queryを使ってAPIの呼び出し・キャッシュ・リフレッシュを効率よく管理する方法を解説します。お楽しみに!

コメント

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