VueからNuxtへの移行ガイド 第3弾【状態管理編】

VueからNuxtへの移行を解説する連載の第3弾です。第1弾の「ルーティング」、第2弾の「データフェッチ」に続き、今回は「状態管理」に焦点を当てて解説します。 Nuxt.jsでアプリケーションを構築する上で、状態管理は重要なテーマの一つです。コンポーネント間でのデータ共有、UIの状態維持、サーバーから取得したデータの保持など、その役割は多岐にわたります。

本稿では、Nuxtが提供する2つの主要な状態管理ツール、useStatePiniaの役割を明確にし、それらをどのように使い分けるべきかを解説します。 さらに、認証のような実践的な設計パターンまで、具体的なコードと共に掘り下げていきます。 本稿が、既存のVue資産を活かしつつ、より堅牢でパフォーマンスの高いアプリケーションへと進化させるための一助となれば幸いです。

目次を読み込み中…

はじめに

本記事は下記第1弾をはじめとするVue to Nuxtの連載シリーズです。
ぜひ順に読んでみてください。

Nuxtの状態管理ツール

Nuxtには、SSRフレンドリーな状態管理のために、useStatePiniaという2つの主要なツールが用意されています。 それぞれの役割とユースケースを理解し、適切に使い分けることが、堅牢なアプリケーション設計の鍵となります。

コンポーザブルによるシンプルな状態共有

Nuxtでは、composablesディレクトリに状態管理のための関数(コンポーザブル)を配置するのが一般的なパターンです。 この方法の利点は、関心事を特定のファイルにカプセル化し、アプリケーションのどこからでも再利用可能な形で状態を管理できる点にあります。

このパターンの中心となるのが、Nuxtに組み込まれたuseStateコンポーザブルです。 これはコンポーネントツリーを越えてリアクティブな状態を共有するための基本的なツールで、サーバーで作成された状態がクライアントに引き継がれることを保証します。

useStateが担うのは、複雑なロジックを伴わないシンプルな状態の共有です。 主な用途として、サイドバーの開閉状態といったUIの状態管理が挙げられます。

vue
export const useSidebarOpen = () => useState<boolean>('isSidebarOpen', () => false)
vue
<script setup lang="ts">
const isSidebarOpen = useSidebarOpen()
</script>

<template>
  <button @click="isSidebarOpen = !isSidebarOpen">Toggle Sidebar</button>
</template>

さらに、useStateは他のコンポーザブルと組み合わせることで、より高度な機能を実現できます。 例えば、useCookieと組み合わせて、UIの状態をブラウザに永続化させてみましょう。 以下の例は、ダークモードのON/OFFをクッキーに保存するコンポーザブルです。

vue
export const useDarkMode = () => {
  // 1. useStateでリアクティブな状態を定義
  const isDarkMode = useState<boolean>('darkMode', () => false)

  // 2. useCookieでブラウザに状態を永続化
  const darkModeCookie = useCookie<boolean>('darkMode', {
    // サーバーサイドでもクッキーの値を読み取れるようにする
    default: () => isDarkMode.value,
    watch: true // isDarkModeの値の変更を監視してクッキーを更新
  })

  // 3. useStateの値をクッキーの値と同期
  isDarkMode.value = darkModeCookie.value

  return isDarkMode
}

このようにコンポーザブルを組み合わせることで、関心事を分離しつつ、再利用性の高い状態管理を構築できます。

Piniaストアによるグローバルな状態管理

Piniaは、Vueの公式状態管理ライブラリであり、Nuxtでも第一級のサポートを受けています。 Vuexの後継として、よりシンプルで型安全なAPIを提供し、複雑なアプリケーションの状態を体系的に管理するのに最適です。

Piniaが担うのは、アプリケーション全体で共有される、より構造化された状態ストアの作成です。 State、Getters、Actionsを組み合わせることでビジネスロジックをカプセル化できます。 そのため、ユーザー情報や認証状態、ショッピングカートの内容といった、より複雑なデータセットの管理に向いています。

vue
import { defineStore } from 'pinia'

interface UserProfile {
  id: string;
  name: string;
  bio: string;
}

export const useUserStore = defineStore('user', () => {
  const profile = ref<UserProfile | null>(null)

  function setProfile(newProfile: UserProfile) {
    profile.value = newProfile
  }

  function clearProfile() {
    profile.value = null
  }

  return { profile, setProfile, clearProfile }
})

これらの使い分けには、明確な指針があります。

状態が単一の値で、それに対する複雑なビジネスロジックが不要な場合はuseStateが適しています。 コンポーザブルとして切り出すことで、特定の関心事に閉じた状態を手軽に管理できます。

対照的に、複数のプロパティを持つオブジェクトや、状態を変更するための複数のメソッド(アクション)が必要な場合はPiniaを選びましょう。 状態とその操作を一つのストアにまとめることで、コードの見通しが良くなり、メンテナンス性が向上します。

シンプルで局所的ならuseState、複雑でグローバルならPiniaと考えると良いでしょう。

認証状態を管理する実践的パターン

Nuxtにおける堅牢な認証は、HttpOnlyクッキーと状態管理の組み合わせによって実現されます。 これは、PiniaストアとNuxtのデータ取得機能を組み合わせた、より高度で実践的なパターンです。

フローは以下の通りです。

Cookieの保存

ユーザーがログインすると、サーバーはセッショントークンをHttpOnlyクッキーにセットして返します。HttpOnly属性により、ブラウザのJavaScriptからクッキーにアクセスできなくなります。

「HttpOnly属性だから安全」とはなりません。あくまでブラウザ上からのjavascript実行で呼び出せなくなるだけなので、その他の攻撃手段によりまだまだ安全ではないと言えます。

ユーザ情報の取得、Cookieの利用

次に、アプリケーションのルートコンポーネントである app.vue で、Nuxtのデータ取得機能(useAsyncDataなど)を使います。app.vueは全ページで最初に実行されるため、ここでセッションクッキーを元にサーバーAPIからユーザー情報を取得します。

ユーザ情報の保存

取得されたユーザー情報はPiniaのuserストアに格納されます。

ユーザ情報の利用

アプリケーション内のどのコンポーネントもこのストアを参照することで、一貫したログイン状態を判断できるようになります。

このフローにより、サーバーはリクエストのたびにクッキーでユーザーを認識できるため、最初から正しいログイン状態でページをレンダリングでき、UIのちらつき(ログイン前に一瞬だけ「ログインしてください」と表示されるなど)が発生しません。

vue
<script setup lang="ts">
import { useUserStore } from '~/stores/user'

const userStore = useUserStore()

// アプリケーションの初期ロード時に一度だけ実行
await useAsyncData('session', async () => {
  try {
    const user = await $fetch('/api/auth/me')
    if (user) userStore.setProfile(user)
  } catch {
    userStore.clearProfile()
  }
})
</script>

このapp.vueでの初期化処理により、userStoreには常に最新のユーザー情報が格納されるようになります。

これで、各コンポーネントは安心してuserStoreを参照して、UIを切り替えることができます。 例えば、ヘッダーコンポーネントは次のようになります。

vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '~/stores/user'

const userStore = useUserStore()
const { profile } = storeToRefs(userStore) // リアクティビティを維持して分割代入
</script>

<template>
  <header>
    <nav v-if="profile">
      <p>ようこそ、{{ profile.name }}さん</p>
      <!-- 他のナビゲーションリンク -->
    </nav>
    <nav v-else>
      <a href="/login">ログイン</a>
    </nav>
  </header>
</template>

storeToRefs はPiniaからインポートするヘルパー関数で、ストアのプロパティをリアクティブなrefとして取り出すために使います。 これにより、分割代入してもリアクティビティが失われません。

移行戦略ガイド:Vuexからの移行

既存のVueアプリケーション(特にVuexを利用している場合)からNuxtへ状態管理を移行するための、より具体的な戦略をコード例と共に紹介します。 Vuexの各機能が、Nuxtのどの機能に置き換わるのかをステップ・バイ・ステップで見ていきましょう。

ステップ1:UI状態の移行

Vuexで管理されがちなUIに関するシンプルな状態は、Nuxtではcomposablesに切り出すのが最適です。

移行前(Vuex) Vuexでは、state、mutations、actionsにまたがってUIの状態を管理していました。

vue
export const state = () => ({
  isSidebarOpen: false,
})

export const mutations = {
  SET_SIDEBAR_OPEN(state, isOpen) {
    state.isSidebarOpen = isOpen
  },
}

export const actions = {
  toggleSidebar({ commit, state }) {
    commit('SET_SIDEBAR_OPEN', !state.isSidebarOpen)
  },
}

移行後(Nuxt) この責務は、useStateを使った単一のコンポーザブルファイルに集約できます。

vue
export const useSidebarOpen = () => useState('isSidebarOpen', () => false)

コンポーネントからは const isSidebarOpen = useSidebarOpen() のように呼び出し、isSidebarOpen.value = !isSidebarOpen.value のように直接状態を更新します。

ステップ2:グローバル状態の移行

アプリケーション全体で共有される、より複雑なデータ(ユーザー情報など)はPiniaストアに移行します。

移行前(Vuex)

vue
export const state = () => ({
  user: null,
})

export const mutations = {
  SET_USER(state, user) {
    state.user = user
  },
}

移行後(Nuxt) この状態は、storesディレクトリ内のPiniaストアで管理します。

vue
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const profile = ref(null)
  function setProfile(newProfile) {
    profile.value = newProfile
  }
  return { profile, setProfile }
})

ステップ3:データ取得アクションの移行

Vuexのアクションに書かれていたデータ取得ロジックは、Nuxtのデータ取得機能(主にuseAsyncData)に移管します。

移行前(Vuex)

vue
export const actions = {
  async fetchUser({ commit }) {
    const user = await this.$axios.$get('/api/me')
    commit('SET_USER', user)
  },
}

移行後(Nuxt) アプリケーション起動時に必要なグローバルなデータは、app.vueで取得し、Piniaストアに格納するのが定石です。

vue
import { useUserStore } from '~/stores/user'

const userStore = useUserStore()

await useAsyncData('session', async () => {
  const user = await $fetch('/api/me')
  userStore.setProfile(user)
})

このパターンは、前の「認証状態を管理する実践的パターン」で解説した通り、SSRと連携してちらつきのないUIを実現する上で非常に重要です。

このように、Vuexが一つにまとめていた責務を、Nuxtのアーキテクチャ(composablesPiniauseAsyncData)に適切に分散させることで、より関心事が分離され、Nuxtのパフォーマンスを最大限に活かせるアプリケーションへと進化させることができます。

まとめ

本稿では、VueからNuxtへの移行における「状態管理」をテーマに、その考え方から具体的な移行戦略までを掘り下げてきました。

Nuxtの状態管理の核心は、責務の分離にあります。 Vuexが一つにまとめていた状態管理の責務を、Nuxtのアーキテクチャに合わせて適切に分散させることが重要です。

具体的には、useState を中心としたコンポーザブルでシンプルな状態を共有しつつ、ユーザー情報のようなアプリケーション全体に関わる複雑な状態はPiniaストアでグローバルに管理するという、2つのパターンを使い分けることが鍵となります。

さらに、データ取得のロジックを状態管理から切り離し、useAsyncDataのようなNuxtのデータ取得機能に任せることで、SSRと調和したパフォーマンスの高いアプリケーションが実現します。 「認証状態を管理する実践的パターン」や「Vuexからの移行ガイド」で示したように、このアーキテクチャは、ちらつきのないUIや関心の分離といった、多くのメリットをもたらします。

Nuxtへの移行は単なるライブラリの置き換えではありません。 アプリケーションのアーキテクチャをより洗練させ、スケーラブルで高品質なWebサービスを構築するための確かな一歩となるでしょう。

この記事は役に立ちましたか?

もし参考になりましたら、下記のボタンで教えてください。

関連記事

コメント

この記事へのコメントはありません。

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)