Nuxtプロジェクトの状態管理をPiniaに移行してみた

この記事は [Sansan Advent Calendar 2021](https://adventar.org/calendars/6219) の14日目の記事です。

今回は気になっていたPiniaを触ってみるべく、個人開発しているWebアプリの状態管理をPiniaに移行してみたのでその手順と所感をまとめたいと思います。
ソースコードは以下です。

https://github.com/misaosyushi/annime-client


# 環境, 前提

- Node.js 16.13.0
- npm 8.1.4
- Nuxt.js 2.15.8 (TypeScript + Composition API)


# Piniaについて

PiniaはVueのグローバルステートマネジメントのためのライブラリです。

https://github.com/posva/pinia

https://pinia.esm.dev/

Vueの状態管理といえばVuexが主流ですが、PiniaはComposition APIにおけるStoreの扱いを再設計するための実験として、Vue.jsコアチームメンバーのposvaさんによって作成されたようです。
そしてVuex5は現在RFCにて絶賛仕様検討, 開発中でPinia作成者であるposvaさんも一緒に作っていて、Piniaからインスパイアを大いに受けているとのことです。
https://github.com/vuejs/rfcs/discussions/270

Pinia, Vuex5の特徴としては

- Mutationsが廃止され、脱Fluxしている
- 完全なTSサポート
- ModuleによるStoreのネストの廃止

などが挙げられます。

これまでのVuexでは状態管理したいだけなのに、まずはFluxの理解からしなければならないのが開発者にとって壁になってなっていた（私はそうだった）ので、サクッと使えるのはとても嬉しいです。また自前で型を拡張せずとも型が効いてくれるのもとってもありがたいです。


# インストール

ではさっそくPiniaを入れていきます。公式の手順通り、Pinia本体とNuxt用のモジュールを追加します。

```
npm i pinia @pinia/nuxt
```

nuxt.config.jsの`buildModules`へ以下を追加します。

```js:nuxt.config.js
buildModules: [
  '@nuxtjs/composition-api/module',
  '@pinia/nuxt', // 追加
],
```

Vuexと共存させたい場合は`disableVuex: false`を指定するとOKです。

```js:nuxt.config.js
buildModules: [
  '@nuxtjs/composition-api/module',
  ['@pinia/nuxt', { disableVuex: false }],
],
```

しかしドキュメントには
https://pinia.esm.dev/ssr/nuxt.html#using-pinia-alongside-vuex

> It is recommended to avoid using both Pinia and Vuex

と書かれてるので移行完了したらVuexは消すのが良さそうです。


続いて、tsconfig.jsonの`types`に`@pinia/nuxt`を追加してコンパイル時に参照する型定義ファイルを指定します。これでオートコンプリートが効いてさくさく開発ができるようになります🎉

```json:tsconfig.json
{
  "types": [
    // ...
    "@pinia/nuxt"
  ]
}
```

# Storeを定義する

インストールとセットアップができたのでStoreを定義していきます。
わたしのアプリではアニメのシーズン名をサイドバーや複数コンポーネントから参照する必要があったため、Storeで管理をしています。

```ts:store/season.ts
import { defineStore } from 'pinia'

export const useSeason = defineStore('season', {
  state: () => ({
    seasonNameText: ''
  }),
  actions: {
    setSeasonNameText(val: string) {
      this.seasonNameText = val
    }
  }
})
```

`defineStore`関数でStoreを定義します。第一引数はユニークな名前にする必要があり、これはidとも呼ばれます。StoreをDevtoolsに接続する際に必要となるみたいです。そして、第2引数のoptionsでstateやgetters, actionsを定義することができます。

またexportする関数名は`useXXXX`と命名するのが慣習だと書かれてたのでそれに従います。
https://pinia.esm.dev/core-concepts/#defining-a-store

> Naming the returned function use... is a convention across composables to make its usage idiomatic.

今回はactionsに`setSeasonNameText()`という関数だけ追加してますが、actionsから直接stateを更新できるのは直感的で良いですね！

# コンポーネントからStoreを参照する

作ったStoreをコンポーネントから呼び出します。（関係ある部分だけ抜粋してます。）

```vue
<template>
  <v-list-item　@click="season.setSeasonNameText(item.seasonNameText)">
　　　　<v-list-item-icon />
　　　　</v-list-item>
</template>

<script lang="ts">
import { defineComponent, SetupContext } from '@vue/composition-api'
import { useSeason } from '@/store/season'

export default defineComponent({
  setup(_props, context: SetupContext) {
    // ...
    const season = useSeason()

    return {
      season
    }
  }
})
</script>
```

先ほど定義した`useSeason()`を呼び出しStoreインスタンスを`season`に代入し、returnしてテンプレート内で使用できるようにします。

`season`にカーソルを当ててみると、、
![スクリーンショット 2021-12-11 12.54.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/0cf3b069-7788-9325-a77b-707d4c687489.png)
ばっちり型推論されてます。

もちろん`setSeasonNameText()`にnumberを渡そうとしたらコンパイルエラーになってくれます。優勝。
![スクリーンショット 2021-12-11 12.59.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/c1df3b66-252f-5936-ad94-0a8b334a6240.png)

# 番外編 永続化しておく

リロードしてStoreが消えてしまうと困るアプリだったので永続化もしておきます。
`pinia-plugin-persist`という素晴らしいライブラリがあったため使わせていただきます。GitHubに記載されてる通り、まだ開発中のため使用する際は自己責任です。

https://github.com/Seb-L/pinia-plugin-persist

## インストール&セットアップ

まずはパッケージ追加。

```
npm i pinia-plugin-persist
```

次にpluginを追加します。Piniaのインターフェースでプラグインを追加するための`use`関数が用意されているため、それに`piniaPersist`を渡します。

```ts:pinia-plugin-persist.client.ts
import { Context } from '@nuxt/types'
import piniaPersist from 'pinia-plugin-persist'

export default ({ app }: Context) => {
  app.pinia?.use(piniaPersist)
}
```

`piniaPersist`では以下のようにアンビエントモジュールで`PersistOptions`という型情報が付加されているため、Piniaで`persist`オブジェクトが使用できるようになります。

```ts:pinia-plugin-persist.d.ts
declare module 'pinia' {
  interface DefineStoreOptions<Id extends string, S extends StateTree, G extends GettersTree<S>, A> {
      persist?: PersistOptions;
  }
}
```

`nuxt.config.js`でプラグインを有効にします。

```js:nuxt.comfig.js
plugins: [
  // ...
  '@/plugins/pinia-plugin-persist.client'
],
```

`tsconfig.json`にも型情報を渡します。

```json:tsconfig.json
{
  "types": [
    // ...
    "pinia-plugin-persist"
  ]
}
```

## Storeで永続化を有効にする

最後はpersistオブジェクトで`enabled: true`を指定することで永続化が有効になります。保存先のストレージのデフォルトはセッションストレージなので、用途によって変更することができます。わたしはローカルストレージを使うようにしました。

```ts:store/season.ts
import { defineStore } from 'pinia'

export const useSeason = defineStore({
  id: 'season',
  state: () => ({
    seasonNameText: ''
  }),
  actions: {
    setSeasonNameText(val: string) {
      this.seasonNameText = val
    }
  },
  // 追加
  persist: {
    enabled: true,
    strategies: [
      { storage: localStorage }
    ]
  }
})
```

これでPiniaでStoreの永続化も実現できました🎉

# まとめ

いままでのVuexでつらみポイントだったFluxパターンの重厚さや、型付けの課題がすっきり解消されてとっても使いやすく感じました。
今回移行したアプリは一部分でしかVuexを使用していなかったこともあり簡単にPiniaに差し替えることができましたが、（公式の推奨ではないものの）Vuex, Piniaの共存もできるので少しづつ移行していくといったことも可能そうです。業務でも使う機会があったら挑戦してみたいと思います。

今後のPiniaのアップデートとVuex5のリリースが楽しみです！
最後まで読んでいただきありがとうございました。