🔋

Vuex4 を Composition API + TypeScript で入門する

11 min read

今月初めにリリースされた Vuex4 を Composition API + TypeScript で試してみたのでそのメモです。
この記事は以下バージョンにて検証しています。

Vuexとは?

Vuex は、Vue.js 公式の状態管理ライブラリです。
Vue アプリケーション内に、どの階層のコンポーネントからでもデータを取得・更新できる単一のデータストアを作ることができます。Vuex を使うことで複数のコンポーネントで使う共有データの Props/Emit による過剰なバケツリレーが不要になります。

また、複雑になりがちな状態管理において以下の図のように特定のルールで制約を与えることでデータの流れを一元化して、コードの構造と保守性を向上させることができます。


What is Vuex?

使い方

Vue CLI で Vue3 + TypeScript の環境構築が行われている前提で、簡単な Todo アプリの状態管理を例に Vuex の一連の流れをみていきます。

インストール + 初期設定

まず Vuex を依存に追加します。

Vuex4 は 2021/02/24 時点で vuex ではなく vuex@next というパッケージ指定なので注意してください。

yarn add vuex@next --save

つぎに Vuex のストアの構造を定義するstore.tsを作成します。

mkdir src/store
touch store/store.ts
src/store/store.ts
import { InjectionKey } from 'vue';
import { createStore, Store, useStore as baseUseStore } from "vuex";

// stateの型定義
type State = {};

// storeをprovide/injectするためのキー
export const key: InjectionKey<Store<State>> = Symbol();

// store本体
export const store = createStore<State>({});

// useStoreを使う時にキーの指定を省略するためのラッパー関数
export const useStore = () => {
  return baseUseStore(key);
}

useStoreのラッパー関数はなくても良いのですが、毎回コンポーネントでストアを取得するためにuseStore(key)と key を指定するのが面倒なので定義しています。コンポーネントからはstore.tsuserStore()を使うようにします(参考)。

そしてストアをmain.tsでプラグインとして設定します。

main.ts
import { createApp } from "vue";
import App from "./App.vue";
import { key, store } from "./store/store";

const app = createApp(App);

app.use(store, key);

app.mount("#app");

これで準備は完了です。

State

state は Vuex で管理する状態そのものです。state はリアクティブなデータとなり、state の変更は Vue.js の変更検知の対象となります。

ストアでの定義

まず store.tsで state の型定義を追加します。

store.ts
// ...
type TodoItem = {
  id: number;
  title: string;
  content: string;
  completed: boolean;
};

type State = {
  todoItems: TodoItem[];
};
// ...

そしてストアの実装であるcreateStoreの引数オブジェクトにstateプロパティを追加して初期値を設定します。

store.ts
// ...
export const store = createStore<State>({
   state: {
     todoItems: [
      {
         id: 1,
        title: "foo",
        content: "bar",
        completed: false
      }
    ]
  }
});
// ...

コンポーネントでの利用

state の利用はstore.tsに定義したuseStoreを使います。ラッパー関数にてprovide/injectのキーは設定しているので、この場でキーの指定は不要です。

src/store/store.ts
<template>
  <div>
    <div v-for="item in todoItems" :key="item.id">
      <p>{{ item.title }}</p>
      <p>{{ item.content }}</p>
      <p>{{ item.completed ? "✅" : "-" }}</p>
      <hr />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, computed } from "@vue/runtime-core";
import { useStore } from "@/store/store"; // store/store.tsのものを利用

export default defineComponent({
  setup() {
    const store = useStore();
    const todoItems = computed(() => store.state.todoItems);

    return {
      todoItems
    };
  }
});
</script>

store.sateで取得できる値はstore.tsで型定義したtype Stateの型が設定されています。 なので state は型安全に使えます。

// 例
// idはnumberと推論される
const id = store.state.todoItems[0].id

Mutations

mutations は state を変更する関数です。
Vuex で state を変更する際は直接 state を書き換えるのではなく、必ず mutation を経由して行うのが Vuex のルールです。

ストアでの定義

まず、mutation の関数名を管理するmutationType.tsを作成します。
関数名を定数や Enum で一元管理しておくと、後述する commit の際に定数で指定できるので便利です。

src/store/mutationType.ts
export const ADD_TODO_ITEM = "ADD_TODO_ITEM";

次にstore.tsの createStore の引数のオブジェクトに mutations をプロパティを追加します。
mutation の関数の第 1 引数には state、第 2 引数には後述する commit の際の引数を受け取れます。その引数を使って関数の内部で state を更新します。

src/store/store.ts
// ...
import * as MutationTypes from "./mutationTypes";

// ...
export const store = createStore<State>({
  // ...
  mutations: {
    [MutationTypes.ADD_TODO_ITEM](state, todoItem: TodoItem) {
      state.todoItems.push(todoItem);
    }
  }
});

コンポーネントでの利用

コンポーネント側での mutations の実効はstore.commitを使います。
以下 TodoItem を追加する例です。

src/components/TodoItemForm.vue
<template>
  <form>
    <label for="title">
      title
      <input type="text" id="title" v-model="form.title" />
    </label>
    <label for="content">
      content
      <input type="text" id="content" v-model="form.content" />
    </label>
    <input type="submit" value="submit" @click.prevent="onSubmit" />
  </form>
</template>

<script lang="ts">
import { defineComponent, reactive } from "@vue/runtime-core";
import { useStore } from "@/store/store";
import * as MutationTypes from "@/store/mutationTypes";

export default defineComponent({
  setup() {
    const form = reactive({
      title: "",
      content: ""
    });

    const clearForm = () => {
      form.title = "";
      form.content = "";
    };

    const store = useStore();

    const onSubmit = () => {
      store.commit(MutationTypes.ADD_TODO_ITEM, {
        id: Math.floor(Math.random() * 100000), // 仮でランダムなIDを設定
        content: form.content,
        title: form.title
      });
      clearForm();
    };

    return {
      onSubmit,
      form
    };
  }
});
</script>

state の変更は、onSubmitstore.commit部分で行っています。
第 1 引数に mutation の関数名の文字列(ここでは mutationTypes の定数を利用)を指定して、第 2 引数で対象の mutation に渡す引数を指定しています。

const onSubmit = async () => {
  await store.commit(MutationTypes.ADD_TODO_ITEM, {
    id: Math.floor(Math.random() * 100000), // 仮でランダムなIDを設定
    content: form.content,
    title: form.title
  });
  clearForm();
};

注意する点として、mutation は同期的に実効されます。非同期処理を mutation の関数内で行はないようにしてください。
※ mutation 内部で非同期処理を書くことは出来ますが、store.commit が Promise を返さないので非同期処理を待つことが出来ない。

コンポーネントから mutation を実効せず、必ず後述の actions を介して mutation を実効という思想もあります。記述は増えますがそちらのほうがストアのデータの流れはシンプルになるので良さそうな気がします。

Actions

action は同期・非同期を問わない state に関するあらゆる操作を定義するものです。
一般的には非同期処理を含む state の変更のトリガーに用いられます。

ストアでの定義

TodoItems の初期値を API 経由で取得する例です。
action も mutation と同様に定数で管理するためにまずactionTypes.tsを追加します。

src/store/actinonTypes.ts
export const INITIALIZE_TODO_ITEMS = "INITIALIZE_TODO_ITEMS";

store の変更は mutation 経由して行うのでmutationTypes.tsにも定数を追加します。

src/store/mutationTypes.ts
// ...
export const INITIALIZE_TODO_ITEMS = "INITIALIZE_TODO_ITEMS";

次にstore.tsの createStore の引数のオブジェクトに actions プロパティを追加します。
actions の関数の第 1 引数にはstate, commit, gettersを含むオブジェクトが受け取れます。actions の関数で async/await を使うことで非同期処理をハンドルできます。

src/store/store.ts
// ...
import * as ActionTypes from "./actionTypes";

// ...
export const store = createStore<State>({
  // ...
  mutations: {
    // ...
    [MutationTypes.INITIALIZE_TODO_ITEMS](store, todoItems: TodoItem[]) {
      store.todoItems = todoItems;
    }
  },
  actions: {
    async [ActionTypes.INITIALIZE_TODO_ITEMS]({ commit }) {
      const todoItems = await fetchAllTodoItems(); // TodoItemsを取得するAPIコール
      commit(ActionTypes.INITIALIZE_TODO_ITEMS, todoItems);
    }
  }
});

今回は todoItem の一覧を取得するfetchAllTodoItemsを実効してその戻値を mutation を介して TodoItems の初期化に使っています。

コンポーネントでの利用

コンポーネントから action を利用する場合はstore.dispatchを使います。
以下はコンポーネントがマウントされるタイミングで dispatch を呼ぶ例です。dispatch は Promise を返すので async/await で処理を待つことができます。

src/components/TodoItems.vue
<!-- ... --> 

<script lang="ts">
import { defineComponent, computed } from "@vue/runtime-core";
import { useStore } from "@/store/store"; // store/store.tsのものを利用
import * as ActionTypes from "@/store/actionTypes"

export default defineComponent({
  async setup() {
    const store = useStore();
    const todoItems = computed(() => store.state.todoItems);

    onMounted(async () => {
      await store.dispatch(ActionTypes.INITIALIZE_TODO_ITEMS)
    })

    return {
      todoItems
    };
  }
});
</script>

ここでは dispatch に引数を渡していませんが、引数を受け取る Actions の場合は第 2 引数で Actions に渡す引数を指定出来ます。

Getters

getter は state からの算出データを定義するものです。state に何らかの処理をした値を複数の場所で利用する場合は、 getter を定義しておくと便利です。

ストアでの定義

store.tsの createStore の引数のオブジェクトに getters プロパティを追加して、完了済みの TodoItems を取得する getter を定義します。

src/store/store
export const store = createStore<State>({
  // ...
  getters: {
    completedTodoItems: store => {
      return store.todoItems.filter(todo => todo.completed);
    }
  }
}

Vue 3.0 の場合は Vue2 系と違い getter の結果がキャッシュされないという不具合があるようです。これはこちらのPRで対応されいます。
Vue 3.1 で解消される見込みとのことです。

コンポーネントでの利用

コンポーネントではstore.gettersで利用出来ます。
ただ、state と違いstore.gettersの戻値は any なので型推論が効きません。

src/components/completedTodoItems.vue
<!-- ... -->
<script lang="ts">
import { defineComponent, computed } from "@vue/runtime-core";
import { useStore } from "@/store/store"; // store/store.tsのものを利用

export default defineComponent({
  setup() {
    const store = useStore();
    const todoItems = computed(() => store.getters.completedTodoItems); // todoItemsはComputedRef<any>と推論される

    return {
      todoItems
    };
  }
});
</script>

その他考慮点

使ってみてわかった Vuex4 採用に関する考慮点を書きます。

TypeScriptの対応がまだ不完全

Vuex の課題として長らく上がっているのが TypeScript への対応だと思います。
commit、dispatch、getters をタイプセーフに実効することが出来ず、型補完を効かせるには vuex-type-helper 等のプラグインを別途使う必要がありました。

TypeScript 4.1 で template literal types が入ったことですし Vuex4 で何らかの改善があるのかなと期待してたところですが、まだ完全な対応は難しいようです。
Vuex4 でも Vuex3 と同様、commit、dispatch、getters で型補完は効きません。別 Plugin を利用するか、独自で型定義を設定して何らかの対応をする必要があるようです。

(独自で型定義する例)

https://dev.to/shubhadip/vue-3-vuex-4-modules-typescript-2i2o

mapHelpersがComposition APIでは使えない

Vue4 + Composition API の場合の現状の問題点が、複数のストア操作を一括で定義できる mapHelpers(mapStatemapGettersmapActionsmapMutations)が使えないことです。
※ Vuex4 でも Option API を使う場合は mapHelpers を使えます。

Vuex を使っている場合はほぼ mapHelpers を使っていると思うので、Vuex4 + Composition API の書き換えの障害になると思われます。

一応以下の Issue が上がっています。早く実装されると良いですね。

https://github.com/vuejs/vuex/issues/1725

終わりに

以上、Vuex4 + Composition API + TypeScript の簡単な紹介でした。
TypeScript の対応や mapHelpers など今すぐの採用は迷うところですが、引き続き動向を追っていきたいです。
その他 Vuex の Moudule 機能が中々ややこしいのでまた別でまとめようと思っています。

参考