🍍

Pinia + Vue 3 + TypeScript による次世代のグローバルステート管理

2021/10/30に公開

概要

Pinia を用いたグローバルステート管理を、表題の組み合わせで TODO ストアを作るサンプル事例です。

https://github.com/posva/pinia

Pinia とは

Vue におけるグローバルステート管理といえば、 Vuex であり、現状は Vue 2 向けの Vuex 3 と、 Vue 3 向けの Vuex 4 が主流となっています。

Vue コアチーム及びコミュニティでは現在、 Vuex 5 の仕様を RFC を通して検討、実装している状態になっており、その仕様は従来の Vuex とは大きく異なるものとなっています。

https://github.com/vuejs/rfcs/discussions/270

Pinia は、 Vuex 5 の新たなアプローチを実験的に実装したグローバルステート管理ライブラリです。

将来的には Vuex 5 に統合する見通しもあるようですが、既に 2.0 がリリースされており、非常に高い実用性を満たしていると言えます。

Vuex 3,4 との違い

Pinia 及び Vuex 5 は、それまでの Vuex と以下のような差があります。

  • TypeScript のフルサポート
  • mutations の廃止
  • ネストされたモジュールの廃止
  • ネームスペースの廃止
  • Code Spliting

mutations を廃止してFluxから外れたり、フラットなストアモジュールで Code Spliting をしやすくするなど、全体的に薄く小さなライブラリとなっています。

そもそも Vue アプリケーションにおいて、Vuex ないし Flux は冗長かつ学習コストが高いという問題があったため、それ未満の軽く使えるレイヤーを用意するのが狙いのようです。

また、 Vuex4 では Vuex3 との互換性が重視されたため、 TypeScript サポートがイマイチに思えた方も多いと思われますが、 Vuex5(or Pinia) ではこれが完全にサポートされる上、非常に型推論されやすく使い勝手の良いライブラリに仕上がっています。(JSだとしても推論が効きやすい)

なお、SSRやプラグイン、devtool といった、現行バージョンでも提供される基本的な仕組みは引き続きサポートされているようですが、本記事ではそこまで扱いません。

サンプルプロジェクトの作成

ここでは、 Vite, Vue3, TypeScript, Pinia の組み合わせで作成します。
なお、Pinia は compositionAPI プラグインを追加することで Vue2 からも利用できます。

$ yarn create @vitejs/app pinia_sample --template vue-ts
$ cd pinia_sample
$ yarn install

Pinia は10月末に出たばかりの 2.0.0 を使用します。

$ yarn add pinia

Vue App へのインストール

Vue インスタンスを生成する際に、 createPinia() を用いて Pinia をインストールしておきます。

src/main.ts
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";

createApp(App).use(createPinia()).mount("#app");

TODO ストアの作成

ここでは、TODOを管理するストアを作成します。

src/store/todos.ts
import { defineStore } from "pinia";

type FilterType = "all" | "finished" | "unfinished";
type TODO = {
  id: number;
  label: string;
  finished: boolean;
};

// defineStore 関数を用いてストアを作成する
// 第一引数 "todos" はアプリケーション全体でストアを特定するためのユニークキー
export const useTodoStore = defineStore("todos", {
  // State は初期値を返す関数を定義する
  state: () => {
    return {
      filter: "all" as FilterType,
      todos: [] as TODO[],
      nextId: 0,
    };
  },
  // getters は state 及び他の getter へのアクセスが可能
  // getter は全て computed 扱いになるため、引数に応じて結果を差し替える場合は関数を戻す
  getters: {
    findTodo(state) {
      return (id: number): TODO => {
        const todo = state.todos.find((todo) => todo.id === id);
        if (todo === undefined) throw new Error("todo not found");

        return todo;
      };
    },
    finishedTodos(state) {
      return state.todos.filter((todo) => todo.finished);
    },
    unfinishedTodos(state) {
      return state.todos.filter((todo) => !todo.finished);
    },
    filteredTodos(state): TODO[] {
      switch (state.filter) {
        case "finished":
          return this.finishedTodos;
        case "unfinished":
          return this.unfinishedTodos;
        default:
          return this.todos;
      }
    },
  },
  // mutations が存在しないので、State の更新は全て actions で行う
  actions: {
    addTodo(label: string) {
      this.todos.push({ id: this.nextId++, label, finished: false });
    },
    toggleTodo(id: number) {
      const todo = this.findTodo(id);
      todo.finished = !todo.finished;
    },
  },
});

コンポーネントからストアを参照する

ここでは Vue3 の script setup を使ったシンプルな例を記載しますが、Vue2 の OptionAPI のような書き方や、 mapGetters/mapActions のようなヘルパー関数の利用も可能です。

src/App.vue
<template>
  <input v-model="state.newTodoLabel" />
  <button @click="addTodo">add</button>

  <input id="all" type="radio" v-model="filter" value="all" />
  <label for="all">すべて</label>
  <input id="finished" type="radio" v-model="filter" value="finished" />
  <label for="finished">完了済み</label>
  <input id="unfinished" type="radio" v-model="filter" value="unfinished" />
  <label for="unfinished">未完了</label>

  <ul>
    <li
      :class="{ todo: true, finished: todo.finished }"
      :key="todo.label"
      v-for="todo in filteredTodos"
      v-text="todo.label"
      @click="toggleTodo(todo.id)"
    />
  </ul>
</template>

<script setup lang="ts">
import { storeToRefs } from "pinia";
import { reactive } from "vue";
import { useTodoStore } from "./store/todos";

const state = reactive({ newTodoLabel: "" });

// useTodoStore を呼び出すだけで、グローバルストアへのアクセスが可能
const store = useTodoStore();

// ストア内の State や Getters はリアクティブオブジェクトなので、
// リアクティブを失わずに取り出す場合は storeToRefs を用いる
const { filteredTodos, filter } = storeToRefs(store);

const toggleTodo = (id: number) => store.toggleTodo(id);
const addTodo = () => {
  if (state.newTodoLabel !== "") {
    store.addTodo(state.newTodoLabel);
    state.newTodoLabel = "";
  }
};
</script>

<style scoped>
.todo {
  user-select: none;
  cursor: pointer;
}
.todo.finished {
  text-decoration: line-through;
  color: gray;
}
</style>

動作イメージ

コンポーネントからグローバルステート上の TODO の読み書きが行えることが確認できました。

所感

Vue3 が少しずつ身近になっていく一方で、 Vue3 になっても Vuex はイマイチ使いづらい感じがありました。

状態管理のための厳密な仕組みを作りたい場合は Flux や、StateMachine の導入が求められるとは思いますが、多くの場合これらは Too Much だと思います。 Vue3 なら compositionAPI と provide/inject パターンで自前管理するという選択肢も取れましたが、それもルールの整備や統率が難しく自由になりすぎる恐れがあります。

そんな中、必要十分な薄さで、複雑なコードを書かずとも型安全なステート管理がすぐにできるライブラリがコアチームから提供されることは大変ありがたい限りです。

ストア同士が疎結合で、必要な場面でのみ必要なストアを import するだけで済む仕組みであることから、単なる状態管理だけでなく、機能ごとのドメインロジックの切り出しも容易にできる可能性があるなと感じました。

Pinia や Vuex5 は従来とは大きく異るアプローチであることから、現行のプロダクトで移行していくには課題も少なからずあるとは思いますが、少しずつ Too Much な用途を正しく小さくしていければなと思います。

GitHubで編集を提案

Discussion