【Vue】Piniaを使った状態管理

## はじめに
Vueで状態管理する時ってリアクティブなデータを使う以外に何があるんだ？と思ったので調べてみました。
Vue(※Vue2)では[Pinia](https://pinia.vuejs.org/)という状態管理ライブラリとの相性が良さそうでした🍍

※正しい読み方は「**ピーニャ**」です（ピニアと呼んでしまってました🙈）
※Vue3と当初書いてましたが、実際に使ったのはVue2でした🙇

### Piniaの特徴とメリット
- **シンプルで軽量なAPI**：[Vuex](https://vuex.vuejs.org/ja/)に比べて、より簡潔なコードで状態を定義・管理することができます
- **TypeScriptとの高い親和性**：TypeScriptとの連携がスムーズで、型安全な開発が可能です
- **モジュール化:**：ストアをモジュール化することで、必要な部分だけをインポートし、不要な部分をロードしないようにできます


## 状態管理とは？
アプリケーションの状態をデータとして扱い、データの変更を一元的に管理することです。

Webアプリケーションにおいては、以下のような状態があります。
こうした状態を表現するデータを扱います。
|状態  |具体例|
|---|---|
|UIの状態  |モーダル表示状態やタブ選択状態|
|DB からのデータ	|ブログの記事データ、商品データ|
|通信処理の状態	|あるデータの読み込み中|
|ナビゲーションの状態	|アプリケーションのどの位置にいるのか

> [フロントエンドの「状態」の入門](https://zenn.dev/shava2c/articles/ae668b689be50d)より

## 実装例
### 開発環境
|言語（FWなど）  |バージョン|
|---|---|
|Ruby  |3.0.3|
|Rails	|6.1.4|
|Vue	|2.6.14|
|nuxt-edge	|latest|
|pinia |2.2.2|

### 実現したいこと
ユーザー情報編集ページで、サーバー側のバリデーションのエラーが発生した場合に、メールアドレスのフィールド下へエラーメッセージを表示する。

:::note warn
要件によりますが、フロント側とサーバー側の両方でバリデーションを実装することが多いです。
バックエンドでのバリデーションは、万が一フロントエンドのバリデーションを通過して不正なデータが送信された場合のセキュリティ対策です。
:::

### 処理の流れ
1. フロント側から「test@」などのような値を入力してリクエストを送る
1. サーバー側からステータスコード422とエラーメッセージをJSONで返す
1. フロント側のプラグイン内でaxiosライブラリを使ってAPIからエラーを受け取る
1. piniaの状態管理で、APIから取得したエラーメッセージに値を更新する（初期値だとエラーメッセージは空の配列に設定している）
1. ユーザー情報を入力するコンポーネントで項番4で更新された値を取得する
1. `computed`プロパティを使い、取得したエラーメッセージの値に変更して表示する
-  入力例は以下になります
:::note info 
test@example.com
:::

:::note alert
test@
:::

### バックエンド側
#### Usersコントローラー
```app/controllers/api/users_controller.rb
  def update
    if @user.update!(user_params)
      render json: :ok
    else
      render json: { errors: @user.error.full_messages }, status: 422
    end
  end
```
- 更新失敗した時にエラーメッセージを配列で取得し、JSON形式でフロント側に返します

#### Userモデル
```app/models/user.rb
class User < ApplicationRecord
 
 VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email,
            presence: true,
            format: { with: VALID_EMAIL_REGEX },
            uniqueness: true

end
```
- `presence: true`: メールアドレスが入力されているかを検証
- `format: { with: VALID_EMAIL_REGEX }`: 正規表現と値がマッチするかどうかを検証
- `uniqueness`: メールアドレスが一意であるかどうかを検証

#### ja.yml
```config/locales/ja.yml 
ja:
  activerecord:
    attributes:
      user:
        email: 'メールアドレス'
  errors:
    format: "%{attribute}"
    messages:
      blank: "%{attribute}を入力してください"
      invalid: "%{attribute}の形式が正しくありません"
      taken: "%{attribute}は既に存在します"
```
- エラーメッセージの内容を定義します
- `%{attribute}`にattributesで定義したemailプロパティの値を埋め込みます。

### フロントエンド側
#### 設定ファイル
``` nuxt.config.js
import { defineNuxtConfig } from '@nuxt/bridge'
export default defineNuxtConfig({
  modules: ['@nuxtjs/axios',  '@pinia'],
  provide: {
    pinia: (app) => {
      const pinia = createPinia()
      app.use(pinia)
      return pinia
    }
   },
})
```
こちらはnuxt.jsの設定ファイルです。
- [`defineNuxtConfig`](https://nuxt.com/docs/getting-started/configuration#nuxt-configuration): 設定が書かれたオブジェクトを含む`defineNuxtConfig`関数としてエクスポートします
- [`modules`](https://nuxt.com/docs/api/nuxt-config#modules-1): Nuxt.jsの機能を拡張するモジュールを導入します
- [`provide`](https://nuxt.com/docs/api/composables/use-nuxt-app#provide-name-value): コンポーネントに注入するためのグローバル依存関係を定義します。今回で言うと、エラーメッセージの状態をコンポーネント全体で呼び出せるようにするために設定しています
- [`createPinia`](https://pinia.vuejs.org/api/pinia/functions/createPinia.html#Function-createPinia-): Piniaインスタンスを作成します
- [`app.use(pinia)`](https://pinia.vuejs.org/core-concepts/outside-component-usage.html#Using-a-store-outside-of-a-component): 作成したPiniaインスタンスをアプリケーション全体で使用できるようにします

#### ストア
```store/errors/errorStore.ts
import { defineStore } from 'pinia'

export const useErrorStore = defineStore('errors',  {
  state: () => ({
    errors: []
  }),
  actions: {
    setErrors(msg: []) {
      this.errors = msg;
    }
  }
})
```
- [`defineStore`](https://pinia.vuejs.org/core-concepts/#Defining-a-Store): 状態管理を行うためのストアを定義します。第1引数には**ストアを識別する一意な名前**（ID）を定義します。第2引数にストアを定義します
- [`state`](https://pinia.vuejs.org/core-concepts/state.html): 初期状態を返すための関数です。今回はエラーメッセージの初期値を空の配列で定義しています
- [`actions`](https://pinia.vuejs.org/core-concepts/actions.html#Actions): コンポーネントのメソッドに相当します。APIからエラーメッセージの配列を引数として`setErrors`関数で受け取ります。受け取ったエラーメッセージの値でerrorsを更新します
- [`this`](https://qiita.com/takkyun/items/c6e2f2cf25327299cf03): 関数が実行される時点での「**自分自身**」を指します。つまり、関数が属するオブジェクトを指す。今回の場合は、`useErrorStore`で定義された**ストアインスタンス**（ストア定義から生成された、状態管理をするオブジェクト）にあたります


#### ストア定義とストアインスタンス違い
|区分	|ストア定義	|ストアインスタンス|
|---|---|---|
|性質	|設計図|	実体|
|内容	|状態の定義、アクションの定義|	特定の時点での状態|
|ライフサイクル	|アプリケーション全体で共通	|各コンポーネントで生成される|

> [【IT用語】インスタンスとは？現実世界のモノに例えて分かりやすく解説！](https://www.bitlogue.net/article/term/instance/)


#### プラグイン
```plugins/axios.ts
import { AxiosError } from 'axios'
import { defineNuxtPlugin, useRouter, useNuxtApp } from '#app'
import { useErrorStore } from '@/store/errors/errorStore'

export default defineNuxtPlugin(() => {
  const router = useRouter()
  const app = useNuxtApp()
  app.$axios.onError((error: AxiosError) => {
    const code = error?.response?.status
    const errors = error?.response?.data?.errors
    if (code === 422) {
      const pinia = app.provide('pinia', errors)
      const errorStore = useErrorStore(pinia)
      errorStore.setErrors(errors)
      return code
    }
  })
})
```
- `defineNuxtPlugin`: Nuxt3は標準ではdefineNuxtPlugin内で実装します
- 作成した`errorStore.ts`ファイルをインポートします
- [`app.$axios`](https://axios-http.com/ja/docs/instance): グローバルに提供されるAxiosインスタンスです。アプリケーション内のどこからでもアクセスし、HTTPリクエストを行えます
- `.onError((error: AxiosError)`: Axiosインスタンスで発生した全てのHTTPリクエストのエラーを捕捉するためのコールバック関数です。エラーが発生すると、このコールバック関数が呼び出され、AxiosErrorオブジェクトが渡されます。この時に引数errorの値が[AxiosError型](https://dev.classmethod.jp/articles/typescript-typing-exception-objects-in-axios-trycatch/#%25EF%25BC%2588%25E6%2596%25B9%25E6%25B3%25952%25EF%25BC%2589%2520AxiosError%25E3%2581%25AB%25E3%2582%2588%25E3%2582%258B%25E5%259E%258B%25E3%2582%25A2%25E3%2582%25B5%25E3%2583%25BC%25E3%2582%25B7%25E3%2583%25A7%25E3%2583%25B3)であるかを判定します
- [`app.provide('pinia', errors)`](https://ja.vuejs.org/api/application#app-provide): アプリケーション内のすべての子孫コンポーネントにPiniaインスタンスのerrorsの初期状態の値を提供します
- `useErrorStore(pinia)`: 引数piniaがこの関数に渡されてストアが作成されます
- `errorStore.setErrors(errors)`: APIから取得したerrorsが引数で渡されて、ストアの状態が更新されます

#### フォームコンポーネント
```components/molecules/userForm.vue
<template>
 <div v-if="errorMessage.length > 0">
   <p class="error-message">{{ errorMessage }}</p>
 </div>
</template>

<script lang="ts">
  import { defineComponent, ref, computed } from '@vue/composition-api'
  import { createPinia } from 'pinia'
  import { useErrorStore } from '~/store/errors/errorStore'

  export default defineComponent({

  setup() {
    const errorMessages = computed(() => errorStore.errors);

    const errorMessage = computed(() => {
      const errors = errorMessages.value.filter((msg: string) => msg.includes("メールアドレス"))
      return errors.length > 0 ? errors[0] : '';
    });

    return {
      errorMessage,
    }
  }
</script>

<style scoped>
  .error-message {
    color: red;
  }
</style>
```
- `computed(() => errorStore.errors)`: errorsの状態が更新されると新しい配列の値に変更されます
- `errorMessages.value.filter`: [指定された条件に該当する新しい配列を作成します。](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/filter)これは各フィールドごとに個別のエラーメッセージを表示するために使っています
- `msg.includes("メールアドレス")`: メールアドレスという文字列を[含む要素が配列にあるかどうかをtrueまたはfalseで返します。](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/includes)
- `v-if="errorMessage.length > 0"`: エラーメッセージの配列の要素数が1以上の場合に表示します（でないと空の配列でも`[]`が画面上で常に表示されてしまうため）
- `{{ errorMessage }}`: [マスタッシュ構文](https://ja.vuejs.org/guide/essentials/template-syntax#text-interpolation)でerrorMessage変数の値を表示します
- [`style scoped`](https://vue-loader-v14.vuejs.org/ja/features/scoped-css.html): スコープ付きCSSです。スタイルを反映する範囲を対象のコンポーネント内に限定します。これによって他のコンポーネントとのクラス名の衝突などを防ぐことができます

## バリデーションエラー発生時の画面
以下はメールアドレスの例です

![スクリーンショット 2024-08-26 7.06.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677000/1a609b0e-3a1e-5a5c-0fa7-a2b157cb8c8e.png)


## まとめ
VueでPiniaを使うことにより、アプリケーションの状態を一元的に管理できる。
複数のコンポーネント間で同じ状態が共有できる。
ライブラリを使えるようになると、実装の幅が広がりますね。

## 参考記事
- [Pinia（公式）](https://pinia.vuejs.org/)
- [Vuex とは何か？](https://vuex.vuejs.org/ja/)
- [フロントエンドの「状態」の入門](https://zenn.dev/shava2c/articles/ae668b689be50d)
- [Nuxt 設定ファイル](https://v2.nuxt.com/ja/docs/directory-structure/nuxt-config/)