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

Zustandとは?シンプルで型安全な状態管理ライブラリの概要
Zustandは、React向けの軽量で使いやすい状態管理ライブラリです。ReduxやContext APIなどの既存のソリューションと比較して、設定が少なく、ボイラープレートが少ないというメリットがあります。また、TypeScriptとの相性が非常に良いため、型安全な状態管理を実現できます。
Zustandが人気の理由には以下のようなものがあります:
- シンプルさ - 最小限のAPIで直感的に使えます
- パフォーマンス - 不要な再レンダリングを自動的に最適化します
- TypeScriptのサポート - 優れた型推論をサポートしています
- React依存のオプショナル化 - React外でも使用可能です
- デバッグツールとの互換性 - 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ストアに以下のような型安全性のメリットがあります:
- ストアの状態と関数の型チェック
- コンポーネント内での自動補完
- リファクタリングの安全性
- コンパイル時のエラー検出
複雑な状態の管理:ネストされたオブジェクトと配列の取り扱い
実際のアプリケーションでは、単純なカウンターより複雑な状態を管理する必要があります。ここでは、ネストされたオブジェクトや配列を含む複雑な状態の管理方法を見ていきます。
ネストされた状態の更新
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は非常に魅力的な選択肢です。
おすすめコンテンツ
おすすめIT技術2025/5/4TypeScriptのType Guardsで型安全なコードを書く方法:初心者向け完全ガイド
TypeScriptのType Guards(型ガード)は、コードの型安全性を高め、バグを減らすための強力な機能です。このガイドでは、TypeScriptの型ガードの基本から応用まで、実際のコード例を...
続きを読む IT技術2025/5/1TypeScript開発を劇的に効率化する13のベストプラクティス
TypeScriptプロジェクトの開発効率を高めるベストプラクティスを紹介します。プロジェクト設定から型活用テクニック、コードの最適化まで、実務で即役立つ具体例とともに解説し、TypeScriptの真...
続きを読む TypeScript2025/5/14Honoで始める高速Webアプリ開発入門!TypeScriptフレームワークの特徴と実装例
TypeScriptフレームワークHonoの基本から応用まで徹底解説。軽量高速な次世代Webフレームワークの特徴や使い方、HonoXメタフレームワークまで、実践的なコード例を交えてわかりやすく紹介しま...
続きを読む React2025/5/1Next.jsとTypeScriptで作る高速SSGブログ:初心者でも簡単に実装できる完全ガイド
Next.jsとTypeScriptを組み合わせたSSGブログの作り方を解説します。静的サイト生成の利点から実装方法、デプロイまで、初心者でも理解できるようステップバイステップで説明。コードサンプル付...
続きを読む TypeScript2025/5/16TypeScript非同期処理パターン完全ガイド:エラーハンドリングから並行処理まで
TypeScriptにおける非同期処理の基本から応用までを網羅。Promiseの使い方、async/awaitのベストプラクティス、エラーハンドリング、並行処理パターンまで実践的なコード例とともに解説...
続きを読む LangGraph2025/5/12LangGraphで発生する再帰制限とメモリリーク問題を解決する実践的アプローチ
LangGraphを使用する際によく遭遇する再帰制限エラーやメモリリーク問題に対する具体的な解決策を提供します。エラーの原因を理解し、効率的なステート管理とエラーハンドリングの実装方法を学びましょう。
続きを読む JavaScript2025/5/12025年最新!JavaScriptビルドツール完全比較ガイド【Vite vs Turbopack vs esbuild】
JavaScript開発の効率を大幅に向上させるビルドツール比較。Vite、Turbopack、esbuildの特徴、パフォーマンス、適した用途を深掘りし、あなたのプロジェクトに最適なツールの選び方を...
続きを読む IT技術2023/7/23JavaScript入門!初心者に分かりやすい基礎知識と学習法
JavaScriptとは、1995年にNetscape Communications社により開発されたスクリプト言語の一つです。主にWebページの動的な動作を制御するために使われ、Client-sid...
続きを読む