はじめに
前回の記事では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の比較
| 比較 | Jotai | Zustand |
|---|---|---|
| 設計思想 | アトム単位で状態を管理する | 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>
);
}
💡 useAtom・useAtomValue・useSetAtom を使い分けましょう:
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との相性も良い
useAtomValue・useSetAtomを使い分けると不要な再レンダリングを防げる- 派生アトムで計算値(合計金額・件数など)を別のアトムとして表現できる
atomWithStorageでlocalStorageとの自動同期が1行で実現できる- Zustandはストアに状態と操作をまとめて定義してコンポーネント外からも使える
- Zustandの
persistとdevtoolsミドルウェアでlocalStorage永続化・デバッグが簡単になる - UIの細かい状態はJotai・ビジネスロジックはZustandという使い分けも有効
次の記事では、ReactアプリのデータフェッチングライブラリTanStack Queryを使ってAPIの呼び出し・キャッシュ・リフレッシュを効率よく管理する方法を解説します。お楽しみに!
コメント