Zustand × URL クエリパラメータ連動:状態同期で UX を高める

Webアプリケーションを開発していると、ユーザーが設定したフィルターや検索条件を、ページをリロードしても保持したいという要望はありませんか。また、特定の状態をURLで共有できたら、ユーザー同士のコミュニケーションも格段に向上するでしょう。
これらの課題を解決する鍵となるのが、ZustandとURLクエリパラメータの連動です。Zustandの軽量で直感的な状態管理と、URLクエリパラメータの永続性を組み合わせることで、ユーザー体験を大きく向上させることができます。
本記事では、ZustandとURLクエリパラメータを連動させる実装手法について、具体的なコード例とともに詳しく解説いたします。初心者の方でも理解しやすいよう、段階的に実装を進めていきますので、ぜひ最後までお読みください。
背景
Zustandの基本的な状態管理機能
Zustandは、Reactアプリケーションで使用される軽量な状態管理ライブラリです。Reduxと比較して、ボイラープレートが少なく、シンプルな記述で状態管理を実現できる点が特徴です。
以下の図で、Zustandの基本的な状態管理フローを確認してみましょう。
mermaidflowchart LR
component[React Component] -->|状態変更| store[Zustand Store]
store -->|状態購読| component
store -->|状態保持| memory[(メモリ)]
memory -->|状態読み込み| store
基本的なZustandストアの実装は以下のようになります。
typescriptimport { create } from 'zustand'
// ストアの型定義
interface AppState {
count: number
increment: () => void
decrement: () => void
}
typescript// Zustandストアの作成
const useAppStore = create<AppState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
Reactコンポーネントでの使用方法も非常にシンプルです。
typescriptimport React from 'react'
import { useAppStore } from './store'
const Counter: React.FC = () => {
const { count, increment, decrement } = useAppStore()
return (
<div>
<p>カウント: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
)
}
URLクエリパラメータの役割と利点
URLクエリパラメータは、URLの末尾に ?key=value&key2=value2
の形式で追加される情報です。Webアプリケーションにおいて、以下のような重要な役割を果たします。
# | 役割 | 詳細 | 例 |
---|---|---|---|
1 | 状態の永続化 | ページリロードしても状態が保持される | ?filter=active&page=2 |
2 | 共有可能性 | URLを他者と共有して同じ状態を再現できる | 検索結果のURL共有 |
3 | SEO対応 | 検索エンジンがページの状態を理解しやすくなる | カテゴリページのURL |
4 | ブラウザ履歴 | 戻る・進むボタンで状態遷移を管理できる | フィルター変更の履歴 |
URLクエリパラメータの活用により、以下のユーザー体験が実現できます。
mermaidflowchart TD
user[ユーザー操作] -->|フィルター設定| url[URL更新]
url -->|ブックマーク| bookmark[状態保存]
url -->|共有| share[他者との共有]
url -->|リロード| restore[状態復元]
bookmark -->|再訪問| restore
share -->|アクセス| restore
restore -->|同じ状態| display[画面表示]
フロントエンド状態管理の課題
現代のWebアプリケーション開発では、以下のような状態管理に関する課題が存在します。
メモリベースの状態管理の限界
従来のReact stateやZustandのみを使用した状態管理では、状態はメモリ上にのみ保存されます。これにより、以下の問題が発生します。
- ページリロード時に全ての状態が失われる
- ブラウザタブを閉じると設定が消失する
- ユーザーが設定した条件を他者と共有できない
状態の一時性による UX の低下
特に以下のような機能において、状態の一時性は大きなUX低下を招きます。
typescript// 問題のある実装例:メモリのみの状態管理
const useFilterStore = create<FilterState>((set) => ({
searchTerm: '', // ページリロードで消失
category: 'all', // ブックマークできない
sortOrder: 'desc', // 共有不可能
setSearchTerm: (term) => set({ searchTerm: term }),
setCategory: (cat) => set({ category: cat }),
setSortOrder: (order) => set({ sortOrder: order }),
}))
このような実装では、ユーザーが時間をかけて設定したフィルター条件が、些細な操作で消失してしまう可能性があります。
図で理解できる要点
- Zustandは軽量でシンプルな状態管理を提供
- URLクエリパラメータは状態の永続化と共有を実現
- メモリベースの状態管理には限界がある
課題
状態とURLの同期が取れない問題
Webアプリケーションでよく遭遇する問題の一つが、アプリケーションの内部状態とURLの不整合です。以下の図で、この問題の構造を確認してみましょう。
mermaidsequenceDiagram
participant User as ユーザー
participant UI as UIコンポーネント
participant Store as Zustandストア
participant URL as ブラウザURL
User->>UI: フィルター変更
UI->>Store: 状態更新
Note over URL: URLは変更されない
User->>URL: ページリロード
Note over Store: 状態が初期化される
Note over UI: フィルターがリセット
この問題は、以下のようなケースで顕著に現れます。
1. フィルター機能での状態不整合
typescript// 問題のあるコード例
const ProductFilter: React.FC = () => {
const { category, setCategory } = useProductStore()
const handleCategoryChange = (newCategory: string) => {
setCategory(newCategory) // Zustandの状態のみ更新
// URLは更新されない!
}
return (
<select value={category} onChange={(e) => handleCategoryChange(e.target.value)}>
<option value="all">すべて</option>
<option value="electronics">電子機器</option>
<option value="books">書籍</option>
</select>
)
}
2. 検索機能での問題
typescript// URLと状態が同期しない検索実装
const SearchBar: React.FC = () => {
const { searchTerm, setSearchTerm } = useSearchStore()
const handleSearch = (term: string) => {
setSearchTerm(term) // 内部状態のみ更新
// 検索結果のURLが変わらないため、共有不可能
}
return (
<input
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
placeholder="検索キーワードを入力"
/>
)
}
ページリロード時の状態復元
ページリロード時に状態が失われる問題は、ユーザー体験を大きく損なう要因となります。
状態消失のパターン
以下の表で、どのような状況で状態が消失するかを整理します。
# | 操作 | 結果 | ユーザーへの影響 |
---|---|---|---|
1 | ページリロード(F5) | 全状態初期化 | 設定し直しが必要 |
2 | ブラウザタブ復元 | 状態未復元 | 作業の中断 |
3 | 別ページから戻る | 状態リセット | 操作の繰り返し |
4 | URLの直接入力 | 初期状態で表示 | 期待した表示にならない |
実際の問題シナリオ
typescript// ユーザーがこのような操作をした場合
const UserScenario = () => {
// 1. ユーザーが商品リストで複数フィルターを設定
const filters = {
category: 'electronics',
priceRange: [1000, 5000],
inStock: true,
sortBy: 'price-asc'
}
// 2. 気になる商品の詳細ページに移動
// 3. ブラウザの戻るボタンで商品リストに戻る
// 4. 結果:全てのフィルター設定が消失!
return "ユーザーは再度フィルターを設定し直す必要がある"
}
ブックマーク・共有時の状態保持
現代のWebアプリケーションでは、特定の状態を他者と共有したり、後で同じ状態に戻るためのブックマーク機能が重要です。
共有機能の重要性
mermaidflowchart LR
userA[ユーザーA] -->|フィルター設定| state[アプリ状態]
state -->|URL生成| shareUrl[共有可能URL]
shareUrl -->|シェア| userB[ユーザーB]
userB -->|アクセス| sameState[同じ状態を再現]
style shareUrl fill:#e1f5fe
style sameState fill:#e8f5e8
問題となるケース
-
ECサイトでの商品検索結果共有
typescript
// 現在の実装では共有不可能 const searchState = { query: 'iPhone', category: 'electronics', priceMin: 50000, priceMax: 150000, inStock: true } // この状態をURLで表現できない
-
ダッシュボードでの表示設定共有
typescript
// チームメンバーと同じダッシュボード表示を共有したい const dashboardState = { dateRange: '2024-01-01_2024-12-31', metrics: ['revenue', 'users', 'conversion'], chartType: 'line', groupBy: 'month' } // URLに反映されないため、口頭で説明するしかない
ビジネスインパクト
状態の共有ができないことによる影響は以下の通りです。
- カスタマーサポートの効率低下:問題の再現が困難
- チーム間のコミュニケーション阻害:具体的な画面状態を共有できない
- マーケティング効果の減少:特定の商品セットやキャンペーンページを直接共有できない
図で理解できる要点
- 状態とURLの同期不足により、リロード時に情報が失われる
- ブックマークや共有機能が制限され、ユーザビリティが低下する
- ビジネス面でも、サポートやマーケティングに影響が出る
解決策
ZustandとURLクエリパラメータの連動実装
ZustandとURLクエリパラメータを連動させるには、状態変更時にURLを更新し、URL変更時に状態を同期する仕組みが必要です。以下の図で、基本的な連動フローを確認しましょう。
mermaidflowchart LR
component[React Component] -->|状態変更| store[Zustand Store]
store -->|URL更新| browser[ブラウザ URL]
browser -->|URL変更検知| store
store -->|状態同期| component
style store fill:#e3f2fd
style browser fill:#fff3e0
基本的な連動実装
まず、URLクエリパラメータを操作するためのユーティリティ関数を作成します。
typescript// utils/urlParams.ts
export const getQueryParams = (): URLSearchParams => {
if (typeof window === 'undefined') return new URLSearchParams()
return new URLSearchParams(window.location.search)
}
typescriptexport const updateQueryParams = (params: Record<string, string | null>) => {
if (typeof window === 'undefined') return
const searchParams = getQueryParams()
// パラメータの更新または削除
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === '') {
searchParams.delete(key)
} else {
searchParams.set(key, value)
}
})
// URLの更新(ページリロードなし)
const newUrl = `${window.location.pathname}?${searchParams.toString()}`
window.history.pushState({}, '', newUrl)
}
状態とURLの双方向同期を実現するストア
typescript// stores/urlSyncStore.ts
import { create } from 'zustand'
import { getQueryParams, updateQueryParams } from '../utils/urlParams'
interface FilterState {
category: string
searchTerm: string
sortOrder: 'asc' | 'desc'
page: number
}
typescriptinterface FilterStore extends FilterState {
setCategory: (category: string) => void
setSearchTerm: (term: string) => void
setSortOrder: (order: 'asc' | 'desc') => void
setPage: (page: number) => void
initializeFromUrl: () => void
}
typescriptexport const useFilterStore = create<FilterStore>((set, get) => ({
// 初期状態
category: 'all',
searchTerm: '',
sortOrder: 'desc',
page: 1,
// カテゴリ変更時の処理
setCategory: (category) => {
set({ category, page: 1 }) // ページをリセット
updateQueryParams({
category: category === 'all' ? null : category,
page: null // 1ページ目の場合はクエリパラメータから削除
})
},
// 検索語句変更時の処理
setSearchTerm: (searchTerm) => {
set({ searchTerm, page: 1 }) // ページをリセット
updateQueryParams({
search: searchTerm || null,
page: null
})
}
}))
typescript// 続き:ソート順とページ変更、URL初期化処理
export const useFilterStore = create<FilterStore>((set, get) => ({
// ...前の実装
// ソート順変更時の処理
setSortOrder: (sortOrder) => {
set({ sortOrder })
updateQueryParams({ sort: sortOrder })
},
// ページ変更時の処理
setPage: (page) => {
set({ page })
updateQueryParams({ page: page === 1 ? null : page.toString() })
},
// URLから状態を初期化
initializeFromUrl: () => {
const params = getQueryParams()
set({
category: params.get('category') || 'all',
searchTerm: params.get('search') || '',
sortOrder: (params.get('sort') as 'asc' | 'desc') || 'desc',
page: parseInt(params.get('page') || '1', 10)
})
}
}))
カスタムフックによる状態同期
より再利用可能で保守性の高い実装を実現するため、カスタムフックを作成しましょう。
URLパラメータ同期フックの型定義
typescript// hooks/useUrlSync.ts
import { useEffect, useCallback } from 'react'
import { useRouter } from 'next/router'
interface UseUrlSyncOptions<T> {
// 状態をクエリパラメータに変換する関数
stateToParams: (state: T) => Record<string, string | null>
// クエリパラメータを状態に変換する関数
paramsToState: (params: URLSearchParams) => Partial<T>
// 状態の初期化関数
initializeState: (state: Partial<T>) => void
// 現在の状態
currentState: T
}
URLパラメータ同期フックの実装
typescriptexport const useUrlSync = <T extends Record<string, any>>({
stateToParams,
paramsToState,
initializeState,
currentState
}: UseUrlSyncOptions<T>) => {
const router = useRouter()
// URLから状態を初期化
useEffect(() => {
if (router.isReady) {
const params = new URLSearchParams(window.location.search)
const stateFromUrl = paramsToState(params)
initializeState(stateFromUrl)
}
}, [router.isReady])
// 状態変更時にURLを更新
const syncToUrl = useCallback((state: T) => {
const params = stateToParams(state)
const searchParams = new URLSearchParams(window.location.search)
// パラメータの更新
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === '') {
searchParams.delete(key)
} else {
searchParams.set(key, value)
}
})
const newUrl = `${window.location.pathname}?${searchParams.toString()}`
router.replace(newUrl, undefined, { shallow: true })
}, [router])
return { syncToUrl }
}
カスタムフックを活用したストアの改良版
typescript// stores/improvedFilterStore.ts
import { create } from 'zustand'
interface FilterState {
category: string
searchTerm: string
sortOrder: 'asc' | 'desc'
page: number
}
typescriptinterface FilterActions {
setCategory: (category: string) => void
setSearchTerm: (term: string) => void
setSortOrder: (order: 'asc' | 'desc') => void
setPage: (page: number) => void
updateState: (newState: Partial<FilterState>) => void
}
typescriptexport const useFilterStore = create<FilterState & FilterActions>((set) => ({
// 初期状態
category: 'all',
searchTerm: '',
sortOrder: 'desc',
page: 1,
// アクション
setCategory: (category) => set((state) => ({
category,
page: 1 // カテゴリ変更時はページをリセット
})),
setSearchTerm: (searchTerm) => set((state) => ({
searchTerm,
page: 1 // 検索時はページをリセット
})),
setSortOrder: (sortOrder) => set({ sortOrder }),
setPage: (page) => set({ page }),
// 一括更新用(URL同期で使用)
updateState: (newState) => set((state) => ({ ...state, ...newState }))
}))
TypeScriptでの型安全な実装
型安全性を確保しながらURL同期を実装するため、適切な型定義を行いましょう。
型定義の整備
typescript// types/urlSync.ts
export interface UrlSyncable {
// URLパラメータとして表現可能な型
[key: string]: string | number | boolean | null | undefined
}
typescriptexport interface UrlSyncConfig<T extends UrlSyncable> {
// デフォルト値
defaultValues: T
// パラメータの変換ルール
serializers: {
[K in keyof T]: {
// 値をクエリパラメータ文字列に変換
serialize: (value: T[K]) => string | null
// クエリパラメータ文字列を値に変換
deserialize: (param: string | null) => T[K]
}
}
}
型安全なURL同期フックの実装
typescript// hooks/useTypedUrlSync.ts
import { useEffect, useCallback } from 'react'
import { useRouter } from 'next/router'
import { UrlSyncable, UrlSyncConfig } from '../types/urlSync'
export const useTypedUrlSync = <T extends UrlSyncable>(
config: UrlSyncConfig<T>,
currentState: T,
updateState: (state: Partial<T>) => void
) => {
const router = useRouter()
// URLから状態を復元
const restoreFromUrl = useCallback(() => {
if (!router.isReady) return
const params = new URLSearchParams(window.location.search)
const restoredState: Partial<T> = {}
// 各パラメータを型安全に復元
Object.entries(config.serializers).forEach(([key, serializer]) => {
const paramValue = params.get(key)
const typedKey = key as keyof T
if (paramValue !== null) {
restoredState[typedKey] = serializer.deserialize(paramValue)
}
})
updateState(restoredState)
}, [router.isReady, config, updateState])
return { restoreFromUrl }
}
typescript// 型安全なURL同期フック(続き)
export const useTypedUrlSync = <T extends UrlSyncable>(
config: UrlSyncConfig<T>,
currentState: T,
updateState: (state: Partial<T>) => void
) => {
// ...前の実装
// 状態をURLに反映
const syncToUrl = useCallback(() => {
const params = new URLSearchParams()
Object.entries(config.serializers).forEach(([key, serializer]) => {
const typedKey = key as keyof T
const value = currentState[typedKey]
const serialized = serializer.serialize(value)
if (serialized !== null && serialized !== '') {
params.set(key, serialized)
}
})
const newUrl = `${window.location.pathname}?${params.toString()}`
router.replace(newUrl, undefined, { shallow: true })
}, [currentState, config, router])
// 初期化
useEffect(() => {
restoreFromUrl()
}, [restoreFromUrl])
return { syncToUrl, restoreFromUrl }
}
実際の使用例
typescript// components/TypedFilterComponent.tsx
import React, { useEffect } from 'react'
import { useFilterStore } from '../stores/improvedFilterStore'
import { useTypedUrlSync } from '../hooks/useTypedUrlSync'
import { UrlSyncConfig } from '../types/urlSync'
interface FilterState {
category: string
searchTerm: string
sortOrder: 'asc' | 'desc'
page: number
}
typescriptconst filterConfig: UrlSyncConfig<FilterState> = {
defaultValues: {
category: 'all',
searchTerm: '',
sortOrder: 'desc',
page: 1
},
serializers: {
category: {
serialize: (value) => value === 'all' ? null : value,
deserialize: (param) => param || 'all'
},
searchTerm: {
serialize: (value) => value || null,
deserialize: (param) => param || ''
},
sortOrder: {
serialize: (value) => value,
deserialize: (param) => (param as 'asc' | 'desc') || 'desc'
},
page: {
serialize: (value) => value === 1 ? null : value.toString(),
deserialize: (param) => param ? parseInt(param, 10) : 1
}
}
}
typescriptexport const TypedFilterComponent: React.FC = () => {
const store = useFilterStore()
const { syncToUrl } = useTypedUrlSync(filterConfig, store, store.updateState)
// 状態変更時にURL同期
useEffect(() => {
syncToUrl()
}, [store.category, store.searchTerm, store.sortOrder, store.page, syncToUrl])
return (
<div>
<select
value={store.category}
onChange={(e) => store.setCategory(e.target.value)}
>
<option value="all">すべて</option>
<option value="electronics">電子機器</option>
<option value="books">書籍</option>
</select>
<input
value={store.searchTerm}
onChange={(e) => store.setSearchTerm(e.target.value)}
placeholder="検索キーワード"
/>
<select
value={store.sortOrder}
onChange={(e) => store.setSortOrder(e.target.value as 'asc' | 'desc')}
>
<option value="desc">新しい順</option>
<option value="asc">古い順</option>
</select>
</div>
)
}
図で理解できる要点
- ZustandとURLの双方向同期により、状態の永続化を実現
- カスタムフックで再利用可能な実装を構築
- TypeScriptで型安全なURL同期を実現
具体例
フィルター機能付きタスクリストの実装
実際のアプリケーションでZustandとURLクエリパラメータの連動を活用した、フィルター機能付きタスクリストを実装してみましょう。
タスクリストアプリケーションの設計
以下の図で、タスクリストアプリケーションの全体構造を確認します。
mermaidflowchart TD
ui[タスクリスト UI] -->|フィルター操作| store[Zustand Store]
store -->|状態変更| url[URL クエリパラメータ]
url -->|ページリロード| restore[状態復元]
restore -->|初期化| store
store -->|フィルタリング| tasks[表示タスク]
tasks -->|描画| ui
style store fill:#e3f2fd
style url fill:#fff3e0
style tasks fill:#e8f5e8
タスクの型定義
typescript// types/task.ts
export interface Task {
id: string
title: string
description: string
status: 'pending' | 'in-progress' | 'completed'
priority: 'low' | 'medium' | 'high'
createdAt: Date
dueDate?: Date
tags: string[]
}
typescriptexport interface TaskFilter {
status: 'all' | 'pending' | 'in-progress' | 'completed'
priority: 'all' | 'low' | 'medium' | 'high'
searchTerm: string
sortBy: 'createdAt' | 'dueDate' | 'priority' | 'title'
sortOrder: 'asc' | 'desc'
tags: string[]
page: number
pageSize: number
}
タスクストアの状態管理部分
typescript// stores/taskStore.ts
import { create } from 'zustand'
import { Task, TaskFilter } from '../types/task'
interface TaskState {
tasks: Task[]
filter: TaskFilter
loading: boolean
error: string | null
}
typescriptinterface TaskActions {
// タスク操作
addTask: (task: Omit<Task, 'id' | 'createdAt'>) => void
updateTask: (id: string, updates: Partial<Task>) => void
deleteTask: (id: string) => void
// フィルター操作
setStatus: (status: TaskFilter['status']) => void
setPriority: (priority: TaskFilter['priority']) => void
setSearchTerm: (term: string) => void
setSortBy: (sortBy: TaskFilter['sortBy']) => void
setSortOrder: (order: TaskFilter['sortOrder']) => void
setTags: (tags: string[]) => void
setPage: (page: number) => void
// URL同期用
updateFilter: (filter: Partial<TaskFilter>) => void
getFilteredTasks: () => Task[]
}
タスクストアの実装(初期状態とタスク操作)
typescriptexport const useTaskStore = create<TaskState & TaskActions>((set, get) => ({
// 初期状態
tasks: [],
filter: {
status: 'all',
priority: 'all',
searchTerm: '',
sortBy: 'createdAt',
sortOrder: 'desc',
tags: [],
page: 1,
pageSize: 10
},
loading: false,
error: null,
// タスク操作
addTask: (taskData) => {
const newTask: Task = {
...taskData,
id: `task_${Date.now()}`,
createdAt: new Date()
}
set((state) => ({ tasks: [...state.tasks, newTask] }))
},
updateTask: (id, updates) => {
set((state) => ({
tasks: state.tasks.map(task =>
task.id === id ? { ...task, ...updates } : task
)
}))
},
deleteTask: (id) => {
set((state) => ({
tasks: state.tasks.filter(task => task.id !== id)
}))
}
}))
タスクストアの実装(フィルター操作)
typescript// フィルター操作(ページリセット付き)
export const useTaskStore = create<TaskState & TaskActions>((set, get) => ({
// ...前の実装
setStatus: (status) => {
set((state) => ({
filter: { ...state.filter, status, page: 1 }
}))
},
setPriority: (priority) => {
set((state) => ({
filter: { ...state.filter, priority, page: 1 }
}))
},
setSearchTerm: (searchTerm) => {
set((state) => ({
filter: { ...state.filter, searchTerm, page: 1 }
}))
},
setSortBy: (sortBy) => {
set((state) => ({
filter: { ...state.filter, sortBy }
}))
},
setSortOrder: (sortOrder) => {
set((state) => ({
filter: { ...state.filter, sortOrder }
}))
},
setTags: (tags) => {
set((state) => ({
filter: { ...state.filter, tags, page: 1 }
}))
},
setPage: (page) => {
set((state) => ({
filter: { ...state.filter, page }
}))
}
}))
タスクストアの実装(フィルタリングロジック)
typescript// フィルタリングされたタスクを取得
export const useTaskStore = create<TaskState & TaskActions>((set, get) => ({
// ...前の実装
// URL同期用の一括更新
updateFilter: (filterUpdates) => {
set((state) => ({
filter: { ...state.filter, ...filterUpdates }
}))
},
// フィルタリングされたタスクを取得
getFilteredTasks: () => {
const { tasks, filter } = get()
let filteredTasks = tasks
// ステータスフィルター
if (filter.status !== 'all') {
filteredTasks = filteredTasks.filter(task => task.status === filter.status)
}
// 優先度フィルター
if (filter.priority !== 'all') {
filteredTasks = filteredTasks.filter(task => task.priority === filter.priority)
}
// 検索フィルター
if (filter.searchTerm) {
const searchLower = filter.searchTerm.toLowerCase()
filteredTasks = filteredTasks.filter(task =>
task.title.toLowerCase().includes(searchLower) ||
task.description.toLowerCase().includes(searchLower)
)
}
// タグフィルター
if (filter.tags.length > 0) {
filteredTasks = filteredTasks.filter(task =>
filter.tags.every(tag => task.tags.includes(tag))
)
}
return filteredTasks
}
}))
URL同期設定の実装
typescript// config/taskUrlSync.ts
import { TaskFilter } from '../types/task'
import { UrlSyncConfig } from '../types/urlSync'
export const taskFilterUrlConfig: UrlSyncConfig<TaskFilter> = {
defaultValues: {
status: 'all',
priority: 'all',
searchTerm: '',
sortBy: 'createdAt',
sortOrder: 'desc',
tags: [],
page: 1,
pageSize: 10
},
serializers: {
status: {
serialize: (value) => value === 'all' ? null : value,
deserialize: (param) => param as TaskFilter['status'] || 'all'
},
priority: {
serialize: (value) => value === 'all' ? null : value,
deserialize: (param) => param as TaskFilter['priority'] || 'all'
},
searchTerm: {
serialize: (value) => value || null,
deserialize: (param) => param || ''
},
sortBy: {
serialize: (value) => value === 'createdAt' ? null : value,
deserialize: (param) => param as TaskFilter['sortBy'] || 'createdAt'
},
sortOrder: {
serialize: (value) => value === 'desc' ? null : value,
deserialize: (param) => param as TaskFilter['sortOrder'] || 'desc'
},
tags: {
serialize: (value) => value.length > 0 ? value.join(',') : null,
deserialize: (param) => param ? param.split(',') : []
},
page: {
serialize: (value) => value === 1 ? null : value.toString(),
deserialize: (param) => param ? parseInt(param, 10) : 1
},
pageSize: {
serialize: (value) => value === 10 ? null : value.toString(),
deserialize: (param) => param ? parseInt(param, 10) : 10
}
}
}
タスクリストコンポーネントの実装
typescript// components/TaskList.tsx
import React, { useEffect } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useTypedUrlSync } from '../hooks/useTypedUrlSync'
import { taskFilterUrlConfig } from '../config/taskUrlSync'
export const TaskList: React.FC = () => {
const store = useTaskStore()
const { syncToUrl } = useTypedUrlSync(
taskFilterUrlConfig,
store.filter,
store.updateFilter
)
// フィルター変更時にURL同期
useEffect(() => {
syncToUrl()
}, [store.filter, syncToUrl])
const filteredTasks = store.getFilteredTasks()
const startIndex = (store.filter.page - 1) * store.filter.pageSize
const paginatedTasks = filteredTasks.slice(startIndex, startIndex + store.filter.pageSize)
return (
<div className="task-list">
{/* フィルターコントロール */}
<div className="filters">
<select
value={store.filter.status}
onChange={(e) => store.setStatus(e.target.value as TaskFilter['status'])}
>
<option value="all">すべてのステータス</option>
<option value="pending">未着手</option>
<option value="in-progress">進行中</option>
<option value="completed">完了</option>
</select>
<select
value={store.filter.priority}
onChange={(e) => store.setPriority(e.target.value as TaskFilter['priority'])}
>
<option value="all">すべての優先度</option>
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
</select>
<input
type="text"
value={store.filter.searchTerm}
onChange={(e) => store.setSearchTerm(e.target.value)}
placeholder="タスクを検索..."
/>
</div>
</div>
)
}
検索条件とページネーションの状態管理
より複雑な例として、検索機能とページネーションを組み合わせた実装を見てみましょう。
検索結果ページの設計図
mermaidsequenceDiagram
participant User as ユーザー
participant UI as 検索UI
participant Store as 検索ストア
participant API as 検索API
participant URL as URL
User->>UI: 検索キーワード入力
UI->>Store: 検索条件更新
Store->>URL: クエリパラメータ更新
Store->>API: 検索リクエスト
API-->>Store: 検索結果
Store-->>UI: 結果表示
Note over User,URL: ページリロード
URL->>Store: パラメータから状態復元
Store->>API: 同じ条件で検索
API-->>Store: 同じ検索結果
Store-->>UI: 同じ画面を再現
検索機能専用ストアの型定義
typescript// stores/searchStore.ts
import { create } from 'zustand'
interface SearchResult {
id: string
title: string
content: string
category: string
score: number
}
typescriptinterface SearchState {
query: string
category: string
results: SearchResult[]
totalCount: number
page: number
pageSize: number
loading: boolean
error: string | null
facets: {
categories: Array<{ name: string; count: number }>
}
}
検索ストアの実装(状態管理部分)
typescriptinterface SearchActions {
setQuery: (query: string) => void
setCategory: (category: string) => void
setPage: (page: number) => void
setPageSize: (size: number) => void
executeSearch: () => Promise<void>
updateSearchState: (state: Partial<SearchState>) => void
}
export const useSearchStore = create<SearchState & SearchActions>((set, get) => ({
// 初期状態
query: '',
category: 'all',
results: [],
totalCount: 0,
page: 1,
pageSize: 20,
loading: false,
error: null,
facets: { categories: [] }
}))
検索ストアの実装(検索ロジック)
typescriptexport const useSearchStore = create<SearchState & SearchActions>((set, get) => ({
// ...前の実装
// 検索条件変更(ページリセット付き)
setQuery: (query) => {
set({ query, page: 1 })
get().executeSearch()
},
setCategory: (category) => {
set({ category, page: 1 })
get().executeSearch()
},
setPage: (page) => {
set({ page })
get().executeSearch()
},
setPageSize: (pageSize) => {
set({ pageSize, page: 1 })
get().executeSearch()
},
// 検索実行
executeSearch: async () => {
const { query, category, page, pageSize } = get()
set({ loading: true, error: null })
try {
// 実際のAPI呼び出し(例)
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
category: category === 'all' ? undefined : category,
page,
pageSize
})
})
if (!response.ok) {
throw new Error('検索に失敗しました')
}
const data = await response.json()
set({
results: data.results,
totalCount: data.totalCount,
facets: data.facets,
loading: false
})
} catch (error) {
set({
error: error instanceof Error ? error.message : '検索エラーが発生しました',
loading: false
})
}
},
// URL同期用の一括更新
updateSearchState: (newState) => {
set((state) => ({ ...state, ...newState }))
// 状態更新後に検索を実行
if (newState.query !== undefined || newState.category !== undefined) {
get().executeSearch()
}
}
}))
検索ページコンポーネントの実装
typescript// components/SearchPage.tsx
import React, { useEffect, useState, useMemo } from 'react'
import { useSearchStore } from '../stores/searchStore'
import { useTypedUrlSync } from '../hooks/useTypedUrlSync'
// URL同期の設定
const searchUrlConfig = {
defaultValues: {
query: '',
category: 'all',
page: 1,
pageSize: 20
},
serializers: {
query: {
serialize: (value: string) => value || null,
deserialize: (param: string | null) => param || ''
},
category: {
serialize: (value: string) => value === 'all' ? null : value,
deserialize: (param: string | null) => param || 'all'
},
page: {
serialize: (value: number) => value === 1 ? null : value.toString(),
deserialize: (param: string | null) => param ? parseInt(param, 10) : 1
},
pageSize: {
serialize: (value: number) => value === 20 ? null : value.toString(),
deserialize: (param: string | null) => param ? parseInt(param, 10) : 20
}
}
}
typescriptexport const SearchPage: React.FC = () => {
const store = useSearchStore()
const [debouncedQuery, setDebouncedQuery] = useState(store.query)
// URL同期の設定
const { syncToUrl } = useTypedUrlSync(
searchUrlConfig,
{
query: store.query,
category: store.category,
page: store.page,
pageSize: store.pageSize
},
(newState) => store.updateSearchState(newState)
)
// 検索キーワードのデバウンス処理
useEffect(() => {
const timer = setTimeout(() => {
if (debouncedQuery !== store.query) {
store.setQuery(debouncedQuery)
}
}, 300)
return () => clearTimeout(timer)
}, [debouncedQuery])
// URL同期
useEffect(() => {
syncToUrl()
}, [store.query, store.category, store.page, store.pageSize, syncToUrl])
// ページネーション情報の計算
const paginationInfo = useMemo(() => {
const totalPages = Math.ceil(store.totalCount / store.pageSize)
const startItem = (store.page - 1) * store.pageSize + 1
const endItem = Math.min(store.page * store.pageSize, store.totalCount)
return { totalPages, startItem, endItem }
}, [store.totalCount, store.page, store.pageSize])
return (
<div className="search-page">
{/* 検索フォーム */}
<div className="search-form">
<input
type="text"
value={debouncedQuery}
onChange={(e) => setDebouncedQuery(e.target.value)}
placeholder="検索キーワードを入力..."
className="search-input"
/>
<select
value={store.category}
onChange={(e) => store.setCategory(e.target.value)}
className="category-select"
>
<option value="all">すべてのカテゴリ</option>
{store.facets.categories.map(cat => (
<option key={cat.name} value={cat.name}>
{cat.name} ({cat.count})
</option>
))}
</select>
<select
value={store.pageSize}
onChange={(e) => store.setPageSize(parseInt(e.target.value, 10))}
className="page-size-select"
>
<option value="10">10件表示</option>
<option value="20">20件表示</option>
<option value="50">50件表示</option>
</select>
</div>
{/* 検索結果の統計情報 */}
<div className="search-stats">
{store.loading ? (
<p>検索中...</p>
) : (
<p>
{store.totalCount}件中 {paginationInfo.startItem}〜{paginationInfo.endItem}件を表示
{store.query && ` (「${store.query}」の検索結果)`}
</p>
)}
</div>
{/* エラー表示 */}
{store.error && (
<div className="error">
<p>エラー: {store.error}</p>
</div>
)}
{/* 検索結果 */}
<div className="search-results">
{store.results.map(result => (
<div key={result.id} className="result-item">
<h3>{result.title}</h3>
<p>{result.content}</p>
<div className="result-meta">
<span className="category">{result.category}</span>
<span className="score">スコア: {result.score.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
)
}
図で理解できる要点
- タスクリストでは複数のフィルター条件を同時にURL管理
- 検索機能では、リアルタイム検索とページネーションの状態を両立
- デバウンス処理により、無駄なURL更新とAPI呼び出しを削減
まとめ
本記事では、ZustandとURLクエリパラメータを連動させることで、Webアプリケーションのユーザー体験を向上させる手法について解説しました。実装のポイントと運用時の注意点をまとめます。
実装のポイント
1. 双方向の状態同期
ZustandとURLクエリパラメータの双方向同期により、以下の利点が得られます。
- 状態の永続化: ページリロード時も設定が保持される
- 共有機能: URLを通じて特定の状態を他者と共有できる
- ブックマーク対応: ユーザーが状態をブックマークして後で同じ画面に戻れる
2. 型安全性の確保
TypeScriptを活用した型安全な実装により、以下のメリットがあります。
typescript// 型安全なシリアライザーの活用例
const serializers = {
status: {
serialize: (value: TaskStatus) => value === 'all' ? null : value,
deserialize: (param: string | null): TaskStatus =>
(param as TaskStatus) || 'all'
}
}
- 実行時エラーの防止: 型チェックにより不正な値の混入を防げる
- 開発効率の向上: IDEの補完機能により開発速度が向上する
- 保守性の確保: リファクタリング時の影響範囲を把握しやすい
3. パフォーマンス最適化
効率的な実装のために以下の最適化を行いました。
- デバウンス処理: 検索キーワード入力時の無駄なAPI呼び出しを防ぐ
- 浅い比較: Next.jsのshallow routingでページリロードを回避
- 選択的更新: 必要な部分のみのURL更新で処理負荷を軽減
運用時の注意点
1. URLの可読性の維持
クエリパラメータが多くなりすぎると、URLが読みにくくなる問題があります。
javascript// 避けるべき例:長すぎるURL
?status=in-progress&priority=high&search=important&sort=dueDate&order=asc&tags=urgent,work&page=3&size=20
// 改善案:デフォルト値を省略
?status=in-progress&priority=high&search=important&sort=dueDate&tags=urgent,work&page=3
対策として、以下を実践しましょう。
- デフォルト値の省略: 初期値と同じパラメータはURLに含めない
- 短縮形の使用: 長いパラメータ名を短縮(例:
category
→cat
) - 重要度による選別: 本当に必要なパラメータのみをURL化
2. ブラウザ履歴の適切な管理
URL変更時のブラウザ履歴の扱いについて注意が必要です。
typescript// 履歴を残す場合(重要な状態変更)
router.push(newUrl)
// 履歴を残さない場合(マイナーな変更)
router.replace(newUrl, undefined, { shallow: true })
状態変更の重要度による使い分け
変更内容 | 履歴 | 理由 |
---|---|---|
ページ変更 | 残す | ユーザーが戻りたい可能性が高い |
ソート順変更 | 残さない | 頻繁に変更される |
検索キーワード | 残す | 前の検索結果に戻りたい場合がある |
フィルター調整 | 残さない | 試行錯誤で何度も変更する |
3. SEO への配慮
検索エンジン最適化を考慮した実装も重要です。
- canonical URLの設定: 重複コンテンツを防ぐ
- 適切なmetaタグ: 状態に応じたページタイトルと説明文
- 構造化データ: 状態情報を検索エンジンに伝える
4. セキュリティの考慮
URLに機密情報を含めないよう注意しましょう。
typescript// 避けるべき例
const userConfig = {
userId: '12345', // 個人情報
apiKey: 'secret_key', // 機密情報
email: 'user@example.com' // プライベート情報
}
// 推奨例
const publicConfig = {
theme: 'dark',
language: 'ja',
viewMode: 'grid'
}
ZustandとURLクエリパラメータの連動実装により、ユーザーにとって使いやすく、開発者にとって保守しやすいWebアプリケーションを構築できます。本記事で紹介した手法を参考に、ぜひ皆さんのプロジェクトでも活用してみてください。
状態管理とURL同期の組み合わせは、現代のWebアプリケーション開発において重要な技術パターンの一つです。適切に実装することで、ユーザー体験の大幅な向上が期待できるでしょう。
関連リンク
公式ドキュメント
- Zustand 公式ドキュメント - Zustandの基本的な使い方と高度な機能について
- Next.js Router ドキュメント - Next.jsでのルーティングとクエリパラメータ操作
- React Hooks ドキュメント - カスタムフック作成の基礎知識
TypeScript関連
- TypeScript Handbook - 型安全な実装のための参考資料
- Utility Types - Partial、Record等の活用方法
状態管理パターン
- State Management Patterns - Reactアプリケーションの状態管理パターン解説
- URL State Management - URL状態管理のベストプラクティス
パフォーマンス最適化
- React Performance - React アプリケーションのパフォーマンス最適化
- Web Vitals - Webページのパフォーマンス指標
実装例とサンプルコード
- GitHub: Zustand Examples - Zustandの実装例集
- CodeSandbox: URL Sync Demo - 本記事で紹介した実装のライブデモ
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来