T-CREATOR

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

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

Webアプリケーションを開発していると、ユーザーが設定したフィルターや検索条件を、ページをリロードしても保持したいという要望はありませんか。また、特定の状態をURLで共有できたら、ユーザー同士のコミュニケーションも格段に向上するでしょう。

これらの課題を解決する鍵となるのが、ZustandURLクエリパラメータの連動です。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共有
3SEO対応検索エンジンがページの状態を理解しやすくなるカテゴリページの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別ページから戻る状態リセット操作の繰り返し
4URLの直接入力初期状態で表示期待した表示にならない

実際の問題シナリオ

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

問題となるケース

  1. ECサイトでの商品検索結果共有

    typescript// 現在の実装では共有不可能
    const searchState = {
      query: 'iPhone',
      category: 'electronics',
      priceMin: 50000,
      priceMax: 150000,
      inStock: true
    }
    // この状態をURLで表現できない
    
  2. ダッシュボードでの表示設定共有

    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に含めない
  • 短縮形の使用: 長いパラメータ名を短縮(例:categorycat
  • 重要度による選別: 本当に必要なパラメータのみを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アプリケーション開発において重要な技術パターンの一つです。適切に実装することで、ユーザー体験の大幅な向上が期待できるでしょう。

関連リンク

公式ドキュメント

TypeScript関連

状態管理パターン

パフォーマンス最適化

  • React Performance - React アプリケーションのパフォーマンス最適化
  • Web Vitals - Webページのパフォーマンス指標

実装例とサンプルコード