第3回:Nuxt3でのデータ管理実践 – 現場で使える状態管理テクニック

はじめに

前回までの記事では、Nuxt3の開発環境構築ルーティング設計について解説してきました。第3回となる今回は、実務のアプリケーション開発で最も重要な要素の一つとなる「データ管理」に焦点を当てていきます。

Nuxt3では、Vue 3のComposition APIを基盤としながら、useAsyncDatauseFetchといったNuxt独自のデータ管理機能が提供されています。

また、Piniaという強力な状態管理ライブラリもシームレスに統合されています。

しかし、これらの機能を効果的に活用するには、いくつかの重要な検討事項があります:

  • 「ComposablesとPinia、どちらを使うべきか?」
  • 「サーバーサイドのデータをどう効率的に管理するか?」
  • 「パフォーマンスを維持しながら、大規模なデータをどう扱うか?」

本記事では、これらの疑問に答えながら、実務で活用できる具体的なデータ管理手法を紹介していきます。

特に、TypeScriptを活用した型安全な実装や、パフォーマンスを考慮したデータ設計に重点を置いて解説していきます。

1. データ管理の基礎

Nuxt3でのデータ管理を理解する上で、まずはVue 3のComposition APIの基本的な概念を押さえておく必要があります。

これらを正しく理解することで、より効果的なデータ管理が可能になります。

Composition APIの特徴と活用法

Composition APIでは、refreactivecomputedなどの関数を使用してデータを管理します。

以下に、実践的な使用例を示します:

vue
<!-- components/ProductList.vue -->
<script setup lang="ts">
interface Product {
  id: number
  name: string
  price: number
  stock: number
}

// refを使用した単純な値の管理
const searchQuery = ref('')
const currentPage = ref(1)

// reactiveを使用したオブジェクトの管理
const filters = reactive({
  minPrice: 0,
  maxPrice: 1000,
  inStock: true
})

// computedを使用した派生データの計算
const filteredProducts = computed(() => {
  return products.value?.filter(product => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(searchQuery.value.toLowerCase())
    const matchesPrice = product.price >= filters.minPrice 
      && product.price <= filters.maxPrice
    const matchesStock = !filters.inStock || product.stock > 0
    
    return matchesSearch && matchesPrice && matchesStock
  })
})

// ページネーションの計算
const paginatedProducts = computed(() => {
  const startIndex = (currentPage.value - 1) * 10
  return filteredProducts.value?.slice(startIndex, startIndex + 10)
})
</script>

<template>
  <div class="product-list">
    <!-- 検索フィルター -->
    <div class="filters">
      <input
        v-model="searchQuery"
        type="text"
        placeholder="製品を検索..."
        class="search-input"
      />
      <div class="price-filters">
        <input
          v-model.number="filters.minPrice"
          type="number"
          placeholder="最小価格"
        />
        <input
          v-model.number="filters.maxPrice"
          type="number"
          placeholder="最大価格"
        />
      </div>
      <label>
        <input
          v-model="filters.inStock"
          type="checkbox"
        />
        在庫あり商品のみ
      </label>
    </div>

    <!-- 製品リスト -->
    <div class="products-grid">
      <ProductCard
        v-for="product in paginatedProducts"
        :key="product.id"
        :product="product"
      />
    </div>

    <!-- ページネーション -->
    <Pagination
      v-model="currentPage"
      :total-pages="Math.ceil((filteredProducts?.length || 0) / 10)"
    />
  </div>
</template>

ref, reactive, computedの使い分け

それぞれの用途と特徴をまとめると:

  1. refの使用ケース:
    • プリミティブ値(文字列、数値、真偽値)の管理
    • 配列やオブジェクトの管理(refで包むことで変更検知が可能)
    • コンポーネント間で受け渡しする必要がある値
  2. reactiveの使用ケース:
    • 複数のプロパティを持つオブジェクトの管理
    • フォームの入力値など、関連する値をグループ化する場合
    • オブジェクトの内部プロパティを頻繁に更新する場合
  3. computedの使用ケース:
    • 既存のデータから派生する値の計算
    • フィルタリングや並び替えの結果
    • パフォーマンスの最適化が必要な計算処理

Nuxt3特有のデータ管理機能

Nuxt3では、Vue 3の機能に加えて、以下のような独自のデータ管理機能が提供されています:

typescript
// composables/useProducts.ts
export const useProducts = () => {
  // グローバルな状態管理
  const products = useState('products', () => [])
  
  // サーバーからのデータ取得
  const { data, pending, error, refresh } = useAsyncData(
    'products',
    () => $fetch('/api/products')
  )

  // データの更新処理
  const updateProduct = async (id: number, updates: Partial<Product>) => {
    try {
      await $fetch(`/api/products/${id}`, {
        method: 'PATCH',
        body: updates
      })
      // キャッシュの更新
      await refresh()
    } catch (e) {
      console.error('製品の更新に失敗しました:', e)
    }
  }

  return {
    products,
    pending,
    error,
    updateProduct
  }
}

2. Composablesの実践的な活用

Composablesは、Nuxt3アプリケーションでロジックを再利用可能な形で管理するための強力な手段です。

適切に設計されたComposablesを使用することで、コードの重複を避け、メンテナンス性を向上させることができます。

カスタムComposablesの設計パターン

実務で使える具体的なComposablesの実装例を見ていきましょう:

typescript
// composables/usePagination.ts
export const usePagination = <T>(items: Ref<T[]>, itemsPerPage: number = 10) => {
  const currentPage = ref(1)
  const totalPages = computed(() => 
    Math.ceil(items.value.length / itemsPerPage)
  )

  const paginatedItems = computed(() => {
    const start = (currentPage.value - 1) * itemsPerPage
    return items.value.slice(start, start + itemsPerPage)
  })

  const goToPage = (page: number) => {
    currentPage.value = Math.max(1, Math.min(page, totalPages.value))
  }

  const next = () => goToPage(currentPage.value + 1)
  const prev = () => goToPage(currentPage.value - 1)

  return {
    currentPage,
    totalPages,
    paginatedItems,
    goToPage,
    next,
    prev
  }
}

// composables/useForm.ts
interface FormOptions<T> {
  initialValues: T
  validationSchema?: any
  onSubmit: (values: T) => Promise<void>
}

export const useForm = <T extends object>(options: FormOptions<T>) => {
  const values = reactive({ ...options.initialValues }) as T
  const errors = reactive<Record<keyof T, string>>({} as any)
  const isSubmitting = ref(false)

  const validate = async () => {
    if (!options.validationSchema) return true
    try {
      await options.validationSchema.validate(values, { abortEarly: false })
      return true
    } catch (err) {
      err.inner?.forEach((error: any) => {
        errors[error.path] = error.message
      })
      return false
    }
  }

  const handleSubmit = async () => {
    isSubmitting.value = true
    errors.value = {}

    try {
      if (await validate()) {
        await options.onSubmit(values)
      }
    } finally {
      isSubmitting.value = false
    }
  }

  return {
    values,
    errors,
    isSubmitting,
    handleSubmit
  }
}

再利用可能なロジックの分離

API通信を扱うComposablesの例:

typescript
// composables/useApi.ts
interface ApiOptions {
  baseURL?: string
  headers?: Record<string, string>
}

export const useApi = (options: ApiOptions = {}) => {
  const config = useRuntimeConfig()
  const baseURL = options.baseURL || config.public.apiBase

  const execute = async <T>(
    endpoint: string,
    {
      method = 'GET',
      body,
      params,
      headers = {}
    }: {
      method?: string
      body?: any
      params?: Record<string, string>
      headers?: Record<string, string>
    } = {}
  ): Promise<T> => {
    const queryString = params
      ? `?${new URLSearchParams(params).toString()}`
      : ''

    try {
      const response = await $fetch<T>(`${baseURL}${endpoint}${queryString}`, {
        method,
        body,
        headers: {
          'Content-Type': 'application/json',
          ...options.headers,
          ...headers
        }
      })

      return response
    } catch (error) {
      // エラーハンドリング
      const { handleApiError } = useApiError()
      handleApiError(error)
      throw error
    }
  }

  return {
    get: <T>(endpoint: string, params?: Record<string, string>) =>
      execute<T>(endpoint, { params }),
    post: <T>(endpoint: string, body: any) =>
      execute<T>(endpoint, { method: 'POST', body }),
    put: <T>(endpoint: string, body: any) =>
      execute<T>(endpoint, { method: 'PUT', body }),
    delete: <T>(endpoint: string) =>
      execute<T>(endpoint, { method: 'DELETE' })
  }
}

TypeScriptを活用した型安全な実装

型安全性を確保しつつ、柔軟なComposablesを実装する例:

typescript
// types/api.d.ts
interface PaginationParams {
  page?: number
  limit?: number
  sort?: string
  order?: 'asc' | 'desc'
}

interface PaginatedResponse<T> {
  items: T[]
  total: number
  page: number
  totalPages: number
}

// composables/useResource.ts
export const useResource = <T extends { id: number | string }>(
  endpoint: string
) => {
  const api = useApi()
  const items = ref<T[]>([])
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fetch = async (params?: PaginationParams) => {
    loading.value = true
    error.value = null

    try {
      const response = await api.get<PaginatedResponse<T>>(endpoint, params)
      items.value = response.items
      return response
    } catch (e) {
      error.value = e as Error
      throw e
    } finally {
      loading.value = false
    }
  }

  return {
    items,
    loading,
    error,
    fetch
  }
}

これらのComposablesを活用することで、以下のような利点が得られます:

  1. ロジックの再利用性の向上
  2. 関心の分離による保守性の向上
  3. TypeScriptによる型安全性の確保
  4. テストのしやすさ

続きは、より大規模なアプリケーションで必要となるPiniaを使った状態管理について解説していきます。

3. Piniaを使った状態管理

大規模なアプリケーションでは、コンポーネント間で共有する状態を効率的に管理する必要があります。

Piniaは、Vue 3の公式状態管理ライブラリとして、TypeScriptとの親和性が高く、Nuxt3との統合も容易です。

Piniaの基本設定

まず、Piniaをプロジェクトに導入し、基本的な設定を行います:

typescript
// stores/counter.ts
export const useCounterStore = defineStore('counter', {
  // 状態の定義
  state: () => ({
    count: 0,
    loading: false,
  }),

  // ゲッター
  getters: {
    doubleCount: (state) => state.count * 2,
    isPositive: (state) => state.count > 0,
  },

  // アクション
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async fetchCount() {
      this.loading = true
      try {
        const response = await $fetch('/api/count')
        this.count = response.count
      } finally {
        this.loading = false
      }
    },
  },
})

ストアの設計パターン

実務でよく使用する、より実践的なストアの実装例:

typescript
// stores/auth.ts
interface User {
  id: number
  email: string
  name: string
  role: string
}

interface AuthState {
  user: User | null
  token: string | null
  loading: boolean
}

export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    user: null,
    token: null,
    loading: false,
  }),

  getters: {
    isAuthenticated: (state) => !!state.token,
    isAdmin: (state) => state.user?.role === 'admin',
  },

  actions: {
    async login(email: string, password: string) {
      this.loading = true
      try {
        const response = await $fetch('/api/auth/login', {
          method: 'POST',
          body: { email, password },
        })
        this.token = response.token
        this.user = response.user
        
        // トークンの永続化
        localStorage.setItem('token', response.token)
      } finally {
        this.loading = false
      }
    },

    async logout() {
      this.token = null
      this.user = null
      localStorage.removeItem('token')
      navigateTo('/login')
    },

    async fetchUser() {
      if (!this.token) return

      try {
        this.user = await $fetch('/api/auth/me', {
          headers: {
            Authorization: `Bearer ${this.token}`,
          },
        })
      } catch {
        this.logout()
      }
    },
  },
})

// stores/product.ts
interface Product {
  id: number
  name: string
  price: number
  stock: number
}

export const useProductStore = defineStore('product', {
  state: () => ({
    products: [] as Product[],
    selectedProduct: null as Product | null,
    filters: {
      search: '',
      minPrice: 0,
      maxPrice: Infinity,
    },
    loading: false,
  }),

  getters: {
    filteredProducts: (state) => {
      return state.products.filter(product => {
        const matchesSearch = product.name
          .toLowerCase()
          .includes(state.filters.search.toLowerCase())
        const matchesPrice = product.price >= state.filters.minPrice && 
          product.price <= state.filters.maxPrice
        return matchesSearch && matchesPrice
      })
    },

    inStockProducts: (state) => {
      return state.products.filter(product => product.stock > 0)
    },
  },

  actions: {
    async fetchProducts() {
      this.loading = true
      try {
        this.products = await $fetch('/api/products')
      } finally {
        this.loading = false
      }
    },

    async updateProduct(id: number, updates: Partial<Product>) {
      const product = await $fetch(`/api/products/${id}`, {
        method: 'PATCH',
        body: updates,
      })
      
      const index = this.products.findIndex(p => p.id === id)
      if (index !== -1) {
        this.products[index] = product
      }
    },

    setFilters(filters: Partial<typeof this.filters>) {
      this.filters = { ...this.filters, ...filters }
    },
  },
})

Nuxt3でのPiniaベストプラクティス

1. ストアの適切な分割

typescript
// stores/index.ts
export const useStore = () => {
  const auth = useAuthStore()
  const product = useProductStore()
  
  return {
    auth,
    product,
  }
}

2. Composition APIとの統合

vue
<!-- components/ProductManager.vue -->
<script setup lang="ts">
const store = useStore()
const { filteredProducts, loading } = storeToRefs(store.product)

// 商品データの取得
onMounted(() => {
  store.product.fetchProducts()
})

// フィルターの適用
const applyFilters = (filters: any) => {
  store.product.setFilters(filters)
}
</script>

これらのストア設計を踏まえた上で、サーバーとのデータ連携について詳しく見ていきましょう。

4. サーバーとのデータ連携

Nuxt3には、サーバーとのデータ連携を効率的に行うための強力な機能が用意されています。

ここでは、useAsyncDatauseFetchの効果的な使用方法と、実践的なキャッシュ戦略について解説します。

useAsyncDataの活用

useAsyncDataは、サーバーサイドレンダリング(SSR)と相性の良いデータ取得手法を提供します:

typescript
// composables/useArticles.ts
interface Article {
  id: number
  title: string
  content: string
  publishedAt: string
}

export const useArticles = () => {
  const { data: articles, pending, error, refresh } = useAsyncData(
    'articles',
    async () => {
      const api = useApi()
      return api.get<Article[]>('/articles')
    },
    {
      // オプション設定
      watch: [], // 監視する依存関係
      transform: (articles) => {
        // データの加工処理
        return articles.map(article => ({
          ...article,
          publishedAt: new Date(article.publishedAt).toLocaleDateString()
        }))
      },
      default: () => [], // デフォルト値
    }
  )

  return {
    articles,
    pending,
    error,
    refresh
  }
}

実際の使用例:

vue
<!-- pages/articles/index.vue -->
<script setup lang="ts">
const { articles, pending, error } = useArticles()
</script>

<template>
  <div class="articles-page">
    <div v-if="pending">
      <LoadingSpinner />
    </div>
    
    <div v-else-if="error">
      <ErrorMessage :error="error" />
    </div>
    
    <template v-else>
      <ArticleCard
        v-for="article in articles"
        :key="article.id"
        :article="article"
      />
    </template>
  </div>
</template>

useFetchの使い方

useFetchuseAsyncDataのラッパーで、よりシンプルなAPI通信を実現します:

typescript
// composables/useProducts.ts
export const useProducts = () => {
  const config = useRuntimeConfig()
  const route = useRoute()
  
  const { data: products, pending, error, refresh } = useFetch<Product[]>(
    '/api/products',
    {
      baseURL: config.public.apiBase,
      // クエリパラメータの設定
      query: computed(() => ({
        category: route.query.category,
        sort: route.query.sort,
        page: route.query.page
      })),
      // リクエストヘッダーの設定
      headers: {
        'Cache-Control': 'no-cache'
      },
      // オプション設定
      key: computed(() => `products-${route.query.page}-${route.query.category}`),
      default: () => [],
    }
  )

  return {
    products,
    pending,
    error,
    refresh
  }
}

キャッシュ戦略の実装

効率的なデータ取得のためのキャッシュ戦略を実装します:

typescript
// composables/useCachedApi.ts
interface CacheOptions {
  maxAge?: number  // キャッシュの有効期限(ミリ秒)
  staleWhileRevalidate?: boolean  // 古いデータを表示しながら更新
}

export const useCachedApi = () => {
  const cache = useState<Record<string, {
    data: any
    timestamp: number
  }>>('api-cache', () => ({}))

  const fetchWithCache = async <T>(
    key: string,
    fetcher: () => Promise<T>,
    options: CacheOptions = {}
  ) => {
    const {
      maxAge = 5 * 60 * 1000, // デフォルト5分
      staleWhileRevalidate = true
    } = options

    const cached = cache.value[key]
    const now = Date.now()
    const isStale = cached && (now - cached.timestamp > maxAge)

    // キャッシュが有効な場合
    if (cached && !isStale) {
      return cached.data as T
    }

    // 古いデータを返しながら更新
    if (staleWhileRevalidate && cached) {
      fetcher().then(newData => {
        cache.value[key] = {
          data: newData,
          timestamp: now
        }
      })
      return cached.data as T
    }

    // 新規取得
    const newData = await fetcher()
    cache.value[key] = {
      data: newData,
      timestamp: now
    }

    return newData
  }

  const invalidateCache = (key?: string) => {
    if (key) {
      delete cache.value[key]
    } else {
      cache.value = {}
    }
  }

  return {
    fetchWithCache,
    invalidateCache
  }
}

使用例:

typescript
// 実際の使用例
const { fetchWithCache } = useCachedApi()

const fetchUserProfile = async (userId: string) => {
  return await fetchWithCache(
    `user-${userId}`,
    () => $fetch(`/api/users/${userId}`),
    {
      maxAge: 30 * 60 * 1000, // 30分
      staleWhileRevalidate: true
    }
  )
}

続きは、これらのデータ連携を踏まえた上で、パフォーマンス最適化について詳しく見ていきましょう。

5. パフォーマンス最適化

大規模なアプリケーションでは、データ管理の最適化が重要なパフォーマンス要因となります。

ここでは、実践的なパフォーマンス最適化手法について解説します。

データの遅延読み込み

必要なタイミングでデータを読み込むことで、初期表示を高速化します:

typescript
// composables/useInfiniteScroll.ts
interface UseInfiniteScrollOptions<T> {
  fetchItems: (page: number) => Promise<T[]>
  pageSize?: number
}

export const useInfiniteScroll = <T>(options: UseInfiniteScrollOptions<T>) => {
  const items = ref<T[]>([])
  const loading = ref(false)
  const currentPage = ref(1)
  const hasMore = ref(true)

  // Intersection Observerの設定
  const observer = ref<IntersectionObserver | null>(null)
  const lastItemRef = ref<HTMLElement | null>(null)

  const loadMore = async () => {
    if (loading.value || !hasMore.value) return

    loading.value = true
    try {
      const newItems = await options.fetchItems(currentPage.value)
      items.value.push(...newItems)
      
      hasMore.value = newItems.length === (options.pageSize ?? 10)
      currentPage.value++
    } finally {
      loading.value = false
    }
  }

  // 監視の設定
  onMounted(() => {
    observer.value = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        loadMore()
      }
    })
  })

  // 最後の要素の監視
  watch(lastItemRef, (el) => {
    if (el && observer.value) {
      observer.value.observe(el)
    }
  })

  onUnmounted(() => {
    observer.value?.disconnect()
  })

  return {
    items,
    loading,
    hasMore,
    lastItemRef
  }
}

使用例:

vue
<!-- components/PostList.vue -->
<script setup lang="ts">
const { items: posts, loading, hasMore, lastItemRef } = useInfiniteScroll({
  fetchItems: async (page) => {
    return await $fetch('/api/posts', {
      params: { page, limit: 10 }
    })
  }
})
</script>

<template>
  <div class="post-list">
    <PostCard
      v-for="post in posts"
      :key="post.id"
      :post="post"
    />
    
    <!-- 最後の要素に参照を設定 -->
    <div ref="lastItemRef" v-if="hasMore">
      <LoadingSpinner v-if="loading" />
    </div>
  </div>
</template>

メモ化によるパフォーマンス改善

複雑な計算や頻繁なデータ加工を最適化します:

typescript
// composables/useDataProcessing.ts
export const useDataProcessing = <T extends { id: number }>(
  items: Ref<T[]>
) => {
  // メモ化されたソート処理
  const sortedItems = computed(() => {
    console.log('Sorting items...') // デバッグ用
    return [...items.value].sort((a, b) => b.id - a.id)
  })

  // メモ化されたグループ化処理
  const groupedItems = computed(() => {
    console.log('Grouping items...') // デバッグ用
    return items.value.reduce((groups, item) => {
      const key = item.id % 2 === 0 ? 'even' : 'odd'
      return {
        ...groups,
        [key]: [...(groups[key] || []), item]
      }
    }, {} as Record<string, T[]>)
  })

  return {
    sortedItems,
    groupedItems
  }
}

状態管理のデバッグ手法

開発時のデバッグを効率化する機能を実装します:

typescript
// plugins/debug.ts
export default defineNuxtPlugin(() => {
  if (process.dev) {
    // Piniaストアのデバッグ
    watch(() => useNuxtApp().$state, (state) => {
      console.log('Store state changed:', state)
    }, { deep: true })

    // APIリクエストのデバッグ
    const api = useApi()
    const original = api.execute
    api.execute = async (...args) => {
      console.log('API Request:', ...args)
      const start = performance.now()
      try {
        const result = await original.apply(api, args)
        console.log('API Response:', result)
        return result
      } catch (error) {
        console.error('API Error:', error)
        throw error
      } finally {
        const duration = performance.now() - start
        console.log(`Request took ${duration.toFixed(2)}ms`)
      }
    }
  }
})

これらの最適化により、以下のような効果が期待できます:

  1. 初期表示の高速化
  2. メモリ使用量の削減
  3. 不要な再計算の防止
  4. デバッグの効率化

次回は「コンポーネント設計の実践手法 – メンテナンス性の高いNuxt3アプリケーションの作り方」と題して、コンポーネントの設計パターンについて詳しく解説していく予定です。