Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

TypeScriptでの型安全な状態管理:Zustandを使った実践的アプローチ

記事のサムネイル

Zustandとは?シンプルで型安全な状態管理ライブラリの概要

Zustandは、React向けの軽量で使いやすい状態管理ライブラリです。ReduxやContext APIなどの既存のソリューションと比較して、設定が少なく、ボイラープレートが少ないというメリットがあります。また、TypeScriptとの相性が非常に良いため、型安全な状態管理を実現できます。

Zustandが人気の理由には以下のようなものがあります:

  1. シンプルさ - 最小限のAPIで直感的に使えます
  2. パフォーマンス - 不要な再レンダリングを自動的に最適化します
  3. TypeScriptのサポート - 優れた型推論をサポートしています
  4. React依存のオプショナル化 - React外でも使用可能です
  5. デバッグツールとの互換性 - Redux DevToolsが使えます

Zustandのインストールは非常に簡単です:

# npmを使用する場合
npm install zustand

# yarnを使用する場合
yarn add zustand

Zustandは他の状態管理ライブラリに比べて学習コストが低く、小規模から中規模のプロジェクトに特に適しています。以下のセクションでは、TypeScriptでZustandを使った型安全な状態管理の基本から応用までを解説します。

基本的な使い方:Zustandストアの作成と型定義

Zustandでストアを作成する基本的な方法を見ていきましょう。TypeScriptと組み合わせることで、型安全な状態管理を実現できます。

シンプルなストアの作成

まず、最もシンプルなストアの作成例を見てみましょう:

// src/stores/counterStore.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 })
}))

このシンプルな例では、カウンター機能を持つストアを作成しています。create関数にジェネリック型パラメータとしてCounterStateインターフェースを渡すことで、ストアの型を定義しています。

Reactコンポーネントでの使用方法

作成したストアはReactコンポーネント内で以下のように使います:

// src/components/Counter.tsx
import React from 'react'
import { useCounterStore } from '../stores/counterStore'

const Counter: React.FC = () => {
  // ストアから必要な状態と関数を取得
  const count = useCounterStore((state) => state.count)
  const { increment, decrement, reset } = useCounterStore((state) => ({
    increment: state.increment,
    decrement: state.decrement,
    reset: state.reset
  }))

  return (
    <div>
      <h2>カウンター: {count}</h2>
      <button onClick={increment}>増加</button>
      <button onClick={decrement}>減少</button>
      <button onClick={reset}>リセット</button>
    </div>
  )
}

export default Counter

注目すべき点は、ストアから必要な状態や関数だけを選択して取得できることです。これにより、必要なプロパティが変更された場合にのみコンポーネントが再レンダリングされるため、パフォーマンスが最適化されます。

ミドルウェアの追加

Zustandではミドルウェアを使って追加機能を実装できます。例えば、永続化機能を追加するには:

// src/stores/persistedCounterStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
}

export const usePersistedCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 }))
    }),
    {
      name: 'counter-storage', // ストレージのキー
      storage: createJSONStorage(() => localStorage) // ストレージタイプ
    }
  )
)

この例では、persistミドルウェアを使用してカウンターの状態をローカルストレージに保存し、ページの再読み込み後も状態を維持できるようにしています。

TypeScriptによる型の恩恵

TypeScriptを使うことでZustandストアに以下のような型安全性のメリットがあります:

  1. ストアの状態と関数の型チェック
  2. コンポーネント内での自動補完
  3. リファクタリングの安全性
  4. コンパイル時のエラー検出

複雑な状態の管理:ネストされたオブジェクトと配列の取り扱い

実際のアプリケーションでは、単純なカウンターより複雑な状態を管理する必要があります。ここでは、ネストされたオブジェクトや配列を含む複雑な状態の管理方法を見ていきます。

ネストされた状態の更新

Zustandでネストされた状態を更新する場合、イミュータブルな更新パターンを使用します:

// src/stores/userStore.ts
import { create } from 'zustand'

interface User {
  id: string
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
    language: string
  }
}

interface UserState {
  user: User | null
  setUser: (user: User) => void
  updateName: (name: string) => void
  updatePreferences: (preferences: Partial<User['preferences']>) => void
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  updateName: (name) => set((state) => ({
    user: state.user ? { ...state.user, name } : null
  })),
  updatePreferences: (preferences) => set((state) => ({
    user: state.user
      ? {
          ...state.user,
          preferences: { ...state.user.preferences, ...preferences }
        }
      : null
  }))
}))

この例では、ユーザー情報とその設定を管理するストアを作成しています。updatePreferences関数では、ネストされたpreferencesオブジェクトの一部だけを更新できるようにしています。

immerミドルウェアでの簡素化

ネストされた状態の更新をさらに簡素化するには、Zustandのimmerミドルウェアを使用できます:

// src/stores/immerUserStore.ts
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface User {
  id: string
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
    language: string
  }
}

interface UserState {
  user: User | null
  setUser: (user: User) => void
  updateName: (name: string) => void
  updatePreferences: (preferences: Partial<User['preferences']>) => void
}

export const useUserStore = create<UserState>()(
  immer((set) => ({
    user: null,
    setUser: (user) => set({ user }),
    updateName: (name) => set((state) => {
      if (state.user) {
        state.user.name = name // ミュータブルに更新できる
      }
    }),
    updatePreferences: (preferences) => set((state) => {
      if (state.user) {
        Object.assign(state.user.preferences, preferences) // ミュータブルに更新できる
      }
    })
  }))
)

immerミドルウェアを使用すると、一見ミュータブルな方法で状態を更新できますが、内部的にはイミュータブルな更新が行われます。これにより、ネストされた状態の更新が格段に簡単になります。

配列の操作

配列を含む状態を管理する例を見てみましょう:

// src/stores/todoStore.ts
import { create } from 'zustand'

interface Todo {
  id: string
  text: string
  completed: boolean
}

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: string) => void
  removeTodo: (id: string) => void
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  addTodo: (text) => set((state) => ({
    todos: [...state.todos, {
      id: Date.now().toString(),
      text,
      completed: false
    }]
  })),
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    )
  })),
  removeTodo: (id) => set((state) => ({
    todos: state.todos.filter(todo => todo.id !== id)
  }))
}))

この例では、Todo配列を管理するストアを作成し、配列の追加、更新、削除の操作を実装しています。配列操作でも常にイミュータブルな更新を使用しています。

パフォーマンス最適化:不要な再レンダリングを防ぐテクニック

Zustandは優れたパフォーマンス特性を持っていますが、より複雑なアプリケーションでは追加の最適化が必要になる場合があります。ここでは、不要な再レンダリングを防ぐためのテクニックを紹介します。

セレクタを使った最適化

Zustandのセレクタを使うと、ストアの特定の部分だけを購読できます:

// src/components/TodoItem.tsx
import React from 'react'
import { useTodoStore } from '../stores/todoStore'

interface TodoItemProps {
  id: string
}

const TodoItem: React.FC<TodoItemProps> = ({ id }) => {
  // idに基づいて特定のTodoだけを選択
  const todo = useTodoStore(
    (state) => state.todos.find(todo => todo.id === id)
  )
  const toggleTodo = useTodoStore((state) => state.toggleTodo)
  
  // todoが存在しない場合の処理
  if (!todo) return null

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(id)}
      />
      <span style={{
        textDecoration: todo.completed ? 'line-through' : 'none'
      }}>
        {todo.text}
      </span>
    </div>
  )
}

この例では、特定のIDを持つTodoアイテムだけを選択しているため、他のTodoが変更されてもこのコンポーネントは再レンダリングされません。

shallow比較の使用

デフォルトでは、Zustandは厳密等価(===)を使用してストアの変更を検出します。オブジェクトの場合、これは参照が変更された場合にのみ変更と見なされます。しかし、shallow関数を使用すると、オブジェクトの浅い比較を行えます:

// src/components/UserProfile.tsx
import React from 'react'
import { useUserStore } from '../stores/userStore'
import { shallow } from 'zustand/shallow'

const UserProfile: React.FC = () => {
  // オブジェクトの浅い比較を使用
  const { name, email } = useUserStore(
    (state) => ({
      name: state.user?.name,
      email: state.user?.email
    }),
    shallow // 浅い比較を使用
  )

  return (
    <div>
      <h2>{name || 'ゲスト'}</h2>
      <p>{email || 'メールアドレスなし'}</p>
    </div>
  )
}

この例では、nameまたはemailが変更された場合にのみコンポーネントが再レンダリングされます。ユーザーの他のプロパティ(例:preferences)が変更されても、このコンポーネントは再レンダリングされません。

useMemoによる最適化

より複雑なセレクタでは、useMemoを使用して最適化できます:

// src/components/TodoStats.tsx
import React, { useMemo } from 'react'
import { useTodoStore } from '../stores/todoStore'

const TodoStats: React.FC = () => {
  const todos = useTodoStore((state) => state.todos)
  
  // 派生ステートの計算をメモ化
  const stats = useMemo(() => {
    const total = todos.length
    const completed = todos.filter(todo => todo.completed).length
    const pending = total - completed
    const percentCompleted = total === 0
      ? 0
      : Math.round((completed / total) * 100)
    
    return { total, completed, pending, percentCompleted }
  }, [todos])

  return (
    <div>
      <h3>Todo統計</h3>
      <p>全タスク: {stats.total}</p>
      <p>完了: {stats.completed}</p>
      <p>未完了: {stats.pending}</p>
      <p>進捗: {stats.percentCompleted}%</p>
    </div>
  )
}

この例では、TodoリストからさまざまなタスクごとのTodoを計算機能を最適化するためにuseMemoを使用しています。Todoリストが変更されない限り、統計は再計算されません。

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

Zustandを使った実践的な例として、認証システムの実装を見てみましょう。このシステムでは、ユーザーのログイン状態、認証トークン、プロフィール情報などを管理します。

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

interface User {
  id: string
  name: string
  email: string
  role: 'user' | 'admin'
}

interface AuthState {
  user: User | null
  token: string | null
  isAuthenticated: boolean
  isLoading: boolean
  error: string | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
  clearError: () => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      isAuthenticated: false,
      isLoading: false,
      error: null,
      
      login: async (email, password) => {
        set({ isLoading: true, error: null })
        
        try {
          // 実際のAPIコールをここで実装
          const response = await fetch('/api/login', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({ email, password })
          })
          
          if (!response.ok) {
            const errorData = await response.json()
            throw new Error(errorData.message || 'ログインに失敗しました')
          }
          
          const data = await response.json()
          
          set({
            user: data.user,
            token: data.token,
            isAuthenticated: true,
            isLoading: false
          })
        } catch (error) {
          set({
            error: error instanceof Error ? error.message : '不明なエラーが発生しました',
            isLoading: false
          })
        }
      },
      
      logout: () => {
        // ログアウト処理
        set({
          user: null,
          token: null,
          isAuthenticated: false
        })
        
        // 必要に応じてAPIコールを追加
      },
      
      clearError: () => set({ error: null })
    }),
    {
      name: 'auth-storage', // ストレージのキー
      partialize: (state) => ({ 
        // トークンとユーザー情報のみを保存
        token: state.token,
        user: state.user
      })
    }
  )
)

このストアでは、ユーザーのログイン・ログアウト処理と、認証状態の永続化を実装しています。persistミドルウェアを使用して、トークンとユーザー情報をローカルストレージに保存し、ページのリロード後も認証状態を維持できるようにしています。

認証情報の使用例

認証ストアを使ったコンポーネントの例を見てみましょう:

// src/components/LoginForm.tsx
import React, { useState } from 'react'
import { useAuthStore } from '../stores/authStore'

const LoginForm: React.FC = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  
  const { login, isLoading, error, clearError } = useAuthStore((state) => ({
    login: state.login,
    isLoading: state.isLoading,
    error: state.error,
    clearError: state.clearError
  }))
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    await login(email, password)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {error && (
        <div className="error">
          {error}
          <button onClick={clearError}></button>
        </div>
      )}
      
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      
      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

認証状態に基づいたルーティング

認証状態に基づいてルーティングを制御する例も見てみましょう:

// src/components/ProtectedRoute.tsx
import React from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'

interface ProtectedRouteProps {
  requiredRole?: 'user' | 'admin'
}

const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ requiredRole }) => {
  const { isAuthenticated, user } = useAuthStore((state) => ({
    isAuthenticated: state.isAuthenticated,
    user: state.user
  }))
  
  // 認証されていない場合はログインページにリダイレクト
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />
  }
  
  // 特定のロールが必要で、ユーザーがそのロールを持っていない場合
  if (requiredRole && user?.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />
  }
  
  // 認証条件を満たしている場合は子ルートをレンダリング
  return <Outlet />
}

この例では、React Routerと組み合わせて、認証済みユーザーだけがアクセスできる保護されたルートを実装しています。さらに、ユーザーのロールに基づいたアクセス制御も可能です。

応用例:非同期処理とエラーハンドリング

最後に、Zustandを使った非同期処理とエラーハンドリングの応用例を見てみましょう。ここでは、APIからデータを取得し、それに関連するエラーを処理するストアを実装します。

// src/stores/productStore.ts
import { create } from 'zustand'

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

interface ProductsState {
  products: Product[]
  selectedProduct: Product | null
  isLoading: boolean
  error: string | null
  
  // アクション
  fetchProducts: () => Promise<void>
  fetchProductById: (id: string) => Promise<void>
  clearSelectedProduct: () => void
  clearError: () => void
}

export const useProductStore = create<ProductsState>((set, get) => ({
  products: [],
  selectedProduct: null,
  isLoading: false,
  error: null,
  
  fetchProducts: async () => {
    set({ isLoading: true, error: null })
    
    try {
      const response = await fetch('/api/products')
      
      if (!response.ok) {
        throw new Error('商品データの取得に失敗しました')
      }
      
      const products = await response.json()
      set({ products, isLoading: false })
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : '不明なエラーが発生しました',
        isLoading: false
      })
    }
  },
  
  fetchProductById: async (id) => {
    // すでに現在の商品リストに存在する場合はそれを使用
    const existingProduct = get().products.find(p => p.id === id)
    if (existingProduct) {
      set({ selectedProduct: existingProduct })
      return
    }
    
    set({ isLoading: true, error: null })
    
    try {
      const response = await fetch(`/api/products/${id}`)
      
      if (!response.ok) {
        throw new Error('商品データの取得に失敗しました')
      }
      
      const product = await response.json()
      set({ selectedProduct: product, isLoading: false })
    } catch (error) {
      set({
        error: error instanceof Error ? error.message : '不明なエラーが発生しました',
        isLoading: false,
        selectedProduct: null
      })
    }
  },
  
  clearSelectedProduct: () => set({ selectedProduct: null }),
  
  clearError: () => set({ error: null })
}))

この例では、商品リストと選択された商品の状態を管理するストアを実装しています。非同期処理中の読み込み状態とエラー状態も管理し、適切なエラーハンドリングを実装しています。

エラー状態の表示

エラー状態を表示するコンポーネントの例:

// src/components/ErrorDisplay.tsx
import React, { useEffect } from 'react'
import { useProductStore } from '../stores/productStore'

interface ErrorDisplayProps {
  autoHideAfter?: number // 自動的に非表示にするまでの時間(ミリ秒)
}

const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ autoHideAfter }) => {
  const { error, clearError } = useProductStore((state) => ({
    error: state.error,
    clearError: state.clearError
  }))
  
  // 一定時間後にエラーを自動的にクリア
  useEffect(() => {
    if (error && autoHideAfter) {
      const timer = setTimeout(clearError, autoHideAfter)
      return () => clearTimeout(timer)
    }
  }, [error, clearError, autoHideAfter])
  
  if (!error) return null
  
  return (
    <div className="error-container">
      <p>{error}</p>
      <button onClick={clearError}>閉じる</button>
    </div>
  )
}

非同期データの表示

商品リストを表示するコンポーネントの例:

// src/components/ProductList.tsx
import React, { useEffect } from 'react'
import { useProductStore } from '../stores/productStore'
import ErrorDisplay from './ErrorDisplay'

const ProductList: React.FC = () => {
  const { products, isLoading, fetchProducts } = useProductStore((state) => ({
    products: state.products,
    isLoading: state.isLoading,
    fetchProducts: state.fetchProducts
  }))
  
  // コンポーネントマウント時に商品データを取得
  useEffect(() => {
    fetchProducts()
  }, [fetchProducts])
  
  if (isLoading && products.length === 0) {
    return <div>読み込み中...</div>
  }
  
  return (
    <div>
      <h2>商品一覧</h2>
      <ErrorDisplay autoHideAfter={5000} />
      
      {products.length === 0 ? (
        <p>商品がありません</p>
      ) : (
        <ul className="product-list">
          {products.map(product => (
            <li key={product.id} className="product-item">
              <img src={product.imageUrl} alt={product.name} />
              <h3>{product.name}</h3>
              <p className="price">¥{product.price.toLocaleString()}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

以上の例から、Zustandを使用することで、TypeScriptの型安全性を維持しながら、シンプルかつ効率的な状態管理を実現できることがわかります。特に非同期処理やエラーハンドリングのようなよくある課題も、Zustandを使って簡潔に実装できます。

Zustandは柔軟性が高く、小規模なプロジェクトから中規模のプロジェクトまで幅広く対応できるため、モダンなReactアプリケーション開発で人気を集めています。型安全性、シンプルさ、パフォーマンスを重視する開発者にとって、Zustandは非常に魅力的な選択肢です。

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

おすすめの書籍

おすすめ記事

おすすめコンテンツ