Next.jsアプリケーションでのZustand活用術

Next.jsアプリケーションでのZustand活用術

公開: 2025年7月6日
更新: 2025年7月6日
6分で読めます

はじめに

モダンなReactアプリケーション開発において、状態管理は最も重要な要素の一つです。特にNext.jsのような本格的なフレームワークを使用する際、適切な状態管理ライブラリの選択と実装は、アプリケーションの保守性やパフォーマンスに大きな影響を与えます。

今回は、軽量で使いやすい状態管理ライブラリとして注目を集めているZustandを、Next.jsアプリケーションで効果的に活用するためのベストプラクティスをご紹介します。

Zustandとは?なぜNext.jsに最適なのか

Zustandは、ドイツ語で「状態」を意味するReact向けの状態管理ライブラリです。ReduxやContext APIと比較して、以下の特徴があります:

  • 軽量性: バンドルサイズが非常に小さい(2KB程度)
  • シンプルなAPI: 複雑なボイラープレートコードが不要
  • TypeScript完全サポート: 型安全性を保ちながら開発可能
  • SSR対応: Next.jsとの相性が抜群

これらの特徴により、Zustandは特にNext.jsのようなフルスタックフレームワークとの組み合わせで真価を発揮します。

セットアップ:Next.jsプロジェクトにZustandを導入

まずは基本的なセットアップから始めましょう。

npm install zustand # または yarn add zustand

基本的なストアの作成

// stores/useCounterStore.ts import { create } from 'zustand' interface CounterState { count: number increment: () => void decrement: () => void reset: () => void } export const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), }))

ベストプラクティス1: ストアの分割と組織化

単一責任の原則を適用

大規模なアプリケーションでは、機能ごとにストアを分割することが重要です:

// stores/useAuthStore.ts export const useAuthStore = create<AuthState>((set, get) => ({ user: null, isAuthenticated: false, login: async (credentials) => { // ログイン処理 }, logout: () => set({ user: null, isAuthenticated: false }), })) // stores/useCartStore.ts export const useCartStore = create<CartState>((set, get) => ({ items: [], total: 0, addItem: (item) => { // アイテム追加処理 }, removeItem: (id) => { // アイテム削除処理 }, }))

ストアの型定義を明確にする

// types/store.ts export interface User { id: string name: string email: string } export interface AuthState { user: User | null isAuthenticated: boolean isLoading: boolean login: (credentials: LoginCredentials) => Promise<void> logout: () => void checkAuth: () => Promise<void> }

ベストプラクティス2: SSR/SSGとの適切な統合

Next.jsでZustandを使用する際の最大の課題の一つが、サーバーサイドレンダリングとの整合性です。

ハイドレーションエラーの回避

// hooks/useHydrated.ts import { useEffect, useState } from 'react' export const useHydrated = () => { const [hydrated, setHydrated] = useState(false) useEffect(() => { setHydrated(true) }, []) return hydrated } // components/Counter.tsx import { useHydrated } from '@/hooks/useHydrated' import { useCounterStore } from '@/stores/useCounterStore' export const Counter = () => { const hydrated = useHydrated() const { count, increment, decrement } = useCounterStore() if (!hydrated) { return <div>Loading...</div> } return ( <div> <p>Count: {count}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ) }

初期状態の設定

// stores/useUserStore.ts import { create } from 'zustand' interface UserStore { user: User | null setUser: (user: User) => void initializeUser: (initialUser?: User) => void } export const useUserStore = create<UserStore>((set) => ({ user: null, setUser: (user) => set({ user }), initializeUser: (initialUser) => { if (initialUser) { set({ user: initialUser }) } }, })) // pages/_app.tsx import { useEffect } from 'react' import { useUserStore } from '@/stores/useUserStore' function MyApp({ Component, pageProps }: AppProps) { const initializeUser = useUserStore((state) => state.initializeUser) useEffect(() => { // サーバーから取得した初期ユーザー情報を設定 if (pageProps.user) { initializeUser(pageProps.user) } }, [pageProps.user, initializeUser]) return <Component {...pageProps} /> }

ベストプラクティス3: パフォーマンス最適化

セレクターの活用

不要な再レンダリングを避けるため、必要な状態のみを選択します:

// 悪い例:ストア全体を取得 const store = useCounterStore() // 良い例:必要な部分のみを選択 const count = useCounterStore((state) => state.count) const increment = useCounterStore((state) => state.increment) // さらに良い例:複数の値を効率的に選択 const { count, increment } = useCounterStore( (state) => ({ count: state.count, increment: state.increment }), shallow // shallow比較を使用 )

計算済み値の実装

import { create } from 'zustand' interface CartStore { items: CartItem[] addItem: (item: CartItem) => void removeItem: (id: string) => void // 計算済みプロパティ get totalPrice(): number get itemCount(): number } export const useCartStore = create<CartStore>((set, get) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id) })), get totalPrice() { return get().items.reduce((sum, item) => sum + item.price, 0) }, get itemCount() { return get().items.length }, }))

ベストプラクティス4: ミドルウェアの活用

永続化ミドルウェア

import { create } from 'zustand' import { persist } from 'zustand/middleware' export const useSettingsStore = create( persist<SettingsState>( (set) => ({ theme: 'light', language: 'ja', setTheme: (theme) => set({ theme }), setLanguage: (language) => set({ language }), }), { name: 'app-settings', // localStorage キー getStorage: () => localStorage, } ) )

デバッグミドルウェア

import { create } from 'zustand' import { devtools } from 'zustand/middleware' export const useDebugStore = create( devtools<DebugState>( (set) => ({ value: 0, setValue: (value) => set({ value }, false, 'setValue'), }), { name: 'debug-store', } ) )

ベストプラクティス5: 非同期処理のハンドリング

エラーハンドリングを含む非同期アクション

interface ApiStore { data: any[] loading: boolean error: string | null fetchData: () => Promise<void> clearError: () => void } export const useApiStore = create<ApiStore>((set, get) => ({ data: [], loading: false, error: null, fetchData: async () => { set({ loading: true, error: null }) try { const response = await fetch('/api/data') if (!response.ok) { throw new Error('データの取得に失敗しました') } const data = await response.json() set({ data, loading: false }) } catch (error) { set({ error: error instanceof Error ? error.message : '予期しないエラーが発生しました', loading: false }) } }, clearError: () => set({ error: null }), }))

実践例:認証システムの実装

実際のアプリケーションでよく使用される認証システムをZustandで実装してみましょう:

// stores/useAuthStore.ts import { create } from 'zustand' import { persist } from 'zustand/middleware' interface AuthStore { user: User | null token: string | null isAuthenticated: boolean isLoading: boolean login: (credentials: LoginCredentials) => Promise<void> logout: () => void refreshToken: () => Promise<void> checkAuth: () => Promise<void> } export const useAuthStore = create( persist<AuthStore>( (set, get) => ({ user: null, token: null, isAuthenticated: false, isLoading: false, login: async (credentials) => { set({ isLoading: true }) try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }) if (!response.ok) { throw new Error('ログインに失敗しました') } const { user, token } = await response.json() set({ user, token, isAuthenticated: true, isLoading: false }) } catch (error) { set({ isLoading: false }) throw error } }, logout: () => { set({ user: null, token: null, isAuthenticated: false }) }, refreshToken: async () => { const { token } = get() if (!token) return try { const response = await fetch('/api/auth/refresh', { headers: { Authorization: `Bearer ${token}` }, }) if (response.ok) { const { token: newToken } = await response.json() set({ token: newToken }) } } catch (error) { console.error('Token refresh failed:', error) get().logout() } }, checkAuth: async () => { const { token } = get() if (!token) return set({ isLoading: true }) try { const response = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` }, }) if (response.ok) { const user = await response.json() set({ user, isAuthenticated: true }) } else { get().logout() } } catch (error) { get().logout() } finally { set({ isLoading: false }) } }, }), { name: 'auth-storage', partialize: (state) => ({ token: state.token, user: state.user, isAuthenticated: state.isAuthenticated }), } ) )

テスト戦略

Zustandストアのテストも重要な要素です:

// __tests__/stores/useCounterStore.test.ts import { renderHook, act } from '@testing-library/react' import { useCounterStore } from '@/stores/useCounterStore' describe('useCounterStore', () => { beforeEach(() => { useCounterStore.setState({ count: 0 }) }) it('should increment count', () => { const { result } = renderHook(() => useCounterStore()) act(() => { result.current.increment() }) expect(result.current.count).toBe(1) }) it('should reset count', () => { const { result } = renderHook(() => useCounterStore()) act(() => { result.current.increment() result.current.increment() result.current.reset() }) expect(result.current.count).toBe(0) }) })

まとめ

ZustandとNext.jsの組み合わせは、モダンなWebアプリケーション開発において非常に強力な選択肢です。今回ご紹介したベストプラクティスを実践することで:

  • 保守性の向上: 明確な責任分離と型安全性
  • パフォーマンス最適化: 効率的な状態選択と更新
  • 開発体験の向上: シンプルなAPIと豊富なミドルウェア
  • 本番環境での安定性: 適切なエラーハンドリングとSSR対応

これらの恩恵を受けることができます。

特に重要なのは、アプリケーションの規模と要件に応じて適切にストアを設計し、必要に応じてミドルウェアを活用することです。小さく始めて、必要に応じて機能を拡張していくアプローチが成功の鍵となります。

皆さんもぜひこれらのベストプラクティスを参考に、より良いNext.jsアプリケーションを構築してください。

コメントを投稿

メールアドレスは公開されません

最大1000文字まで。マークダウン記法(**太字**、*斜体*、`コード`など)が使用できます

投稿されたコメントは管理者による承認後に表示される場合があります。 不適切な内容は削除される可能性があります。

コメント (0件)

関連記事