カルキチブログ

Composition APIのprovide・injectを使ったVue.jsの状態管理について

新しい職場(最近転職した)ではVueを結構使うようになったので、Vueの記事を書いてみました。

今回の記事では、Composition APIのprovide・injectと呼ばれる関数を使用して、グローバルステートを管理する方法について、TODOアプリの作成例を用いて解説するという記事を書いてみました。

provide・injectを使った状態管理について解説する前に、Composition APIとは?

Composition APIとは、2020年の9月頃にVue3に追加されたコンポーネント設計を行うための新機能です。

TypeScriptの型の恩恵を受けやすくなったり、ビューとロジックを切り離せるようになったことで、ロジックの再利用がしやすくなりました。

使ってみた感じですが、Vueのあんまり良くなかった部分を劇的に改善してくれており、非常に書きやすくなったように思えます。

VueはVue2のときに2週間くらい書いて、よく分からなくて辞めた経験があるのですが、Vu2の時よりは直感的に、ストレスなく書けるように感じました。

provide・injectを使った状態管理をする方法

使用したVueの実行環境は以下のような感じです。

  • Vue 3.0.4
  • TypeScript 4.1.2
  • vite 1.0.0-rc.13

Vueの簡易的な実行環境だとVue CLIが有名ですが、今回はvite(ヴィート)を使用しました。
開発サーバーの起動速度やホットリロードの反映速度が爆速なので、かなり良さげです。

Reactでも使用可能なこと、webpackのconfig周りの細かい設定が可能なことから、webpackの代替としても注目されてるらしいです。

https://tech.recruit-mp.co.jp/front-end/post-21250/

今回の記事を書くのに使用したサンプルコードのリンクも残しておきます。

https://github.com/Yota-K/vue3-todo-sample

git clone後に、yarn or npm installしてlocalhost:3000でアクセスするとサンプルアプリが表示されます。

ステートを定義する

まずはステートを定義していきます。

import { reactive } from 'vue';

// TODOリストの型を定義
type TodoState = {
  todoItems: {
    id: number;
    done: boolean;
    text: string;
  }[];
};

export const todoState = () => {
  const state = reactive<TodoState>({
    todoItems: [],
  });
};

reactiveというメソッドが出てきましたが、このメソッドの役割を公式で確認してみると以下のような説明が書いてありました。

reactive は Vue 2.x における Vue.observable() API に相当し、RxJS における observables との混同を避けるために改名されました。ここで、返される状態はリアクティブオブジェクトです。リアクティブの変換は "deep" であり、渡されたオブジェクトのすべての入れ子になっているプロパティに影響を与えます。

Vue におけるリアクティブな状態の重要なユースケースは描画の際に用いることができることです。依存関係の追跡のおかげで、リアクティブな状態が変化するとビューが自動的に更新されます。

引用: リアクティブの基礎 | Vue.js

Vue.observable()APIとか言われても、Vueの知見がほぼない僕には正直よく分かりませんでした。

もう少し分かりやすい説明がないかもいろいろ探してみました。

引数に渡したオブジェクトのリアクティブなプロキシを返します。

Vue2系のVue.observable()と同等の機能です。ネストされたオブジェクトもリアクティブな値として保持されます。

引用: Vue Composition APIで使えるリアクティブ関連のAPI一覧 - Qiita

こちらの方が理解しやすかったです。

どうやらreactiveという関数を使用すると、オブジェクト形式でコンポーネント内で使用する値の保持ができるらしいです。
→ReactのuseStateに近いような感じで使えます。

以下の記述でTODOリストを格納するための、stateの保持を行うイメージです。

const state = reactive<TodoState>({
  todoItems: [],
});

Vueにはreactiveとは別にrefと呼ばれるメソッドもあるのですが、こいつを使うとオブジェクトではなくプリミティブな値(文字列、数値、BigInt、真偽値)の状態を保持することができます。

Todoリストに必要なinputの状態はオブジェクトで定義する必要はないので、inputの状態はrefを使用して定義することにします。

import { ref } from 'vue';

export const useInputValue = () => {
  const inputValue = ref('');

  return {
    inputValue
  }
};

処理を書いていく

続いて、TODOに必要な処理を書いていきます。
TODOアプリに最低限必要な処理の記述を行います。

import { reactive, InjectionKey, readonly } from 'vue';

// TODOの型を定義
type TodoState = {
  todoItems: {
    id: number;
    done: boolean;
    text: string;
  }[];
};

export const todoState = () => {
  // 管理したいステートを定義
  const state = reactive<TodoState>({
    todoItems: [],
  });

  // TODO追加
  const addTodo = (value: string) => {
    if (!value) {
      alert('値が入力されていません');
      return;
    };

    state.todoItems = [...state.todoItems, {
      id: state.todoItems.length + 1,
      done: false,
      text: value
    }];
  };

  // TODOを削除
  const removeTodo = (id: number) => {
    state.todoItems = state.todoItems.filter(todo => todo.id !== id)
  };

  // TODOのチェック
  const toggleTodo = (id: number) => {
    const todo = state.todoItems.find(todo => todo.id === id);

    if (!todo) return;

    todo.done = !todo.done;
  };

  return {
    state: readonly(state),
    addTodo,
    removeTodo,
    toggleTodo,
  }
};

Composition APIを使用すると、処理を関数単位でまとめることができるので、可読性の高い読みやすいコードが書きやすいです。

後の説明でも書いていきますが、returnで返したステートや関数をコンポーネント内で利用できるようになります。

readonlyを使用すれば、ステートを読み取り専用にすることもできるので、予期しない変更を防げる点もいいですね!

provideメソッドを使用するために必要なInjectionKeyを用意する

今のままだとstateをグローバルで使い回すことが出来ないので、InjectionKeyprovideを使用して、定義した状態をグローバルステートとして使いまわせるようにします。

import { reactive, InjectionKey, readonly } from 'vue';

// TODOの型を定義
type TodoState = {
  todoItems: {
    id: number;
    done: boolean;
    text: string;
  }[];
};

export const todoState = () => {
  // 管理したいステートを定義
  const state = reactive<TodoState>({
    todoItems: [],
  });

  // TODO追加
  const addTodo = (value: string) => {
    if (!value) {
      alert('値が入力されていません');
      return;
    };

    state.todoItems = [...state.todoItems, {
      id: state.todoItems.length + 1,
      done: false,
      text: value
    }];
  };

  // TODOを削除
  const removeTodo = (id: number) => {
    state.todoItems = state.todoItems.filter(todo => todo.id !== id)
  };

  // TODOのチェック
  const toggleTodo = (id: number) => {
    const todo = state.todoItems.find(todo => todo.id === id);

    if (!todo) return;

    todo.done = !todo.done;
  };

  return {
    state: readonly(state),
    addTodo,
    removeTodo,
    toggleTodo,
  }
};

// ステートの型を生成
export type todoStateType = ReturnType<typeof todoState>;

// provideメソッドに指定するInjectionKeyを指定
export const todoStateKey: InjectionKey<todoStateType> = Symbol('todoState');

ここで定義したキー(サンプルコードだとtodoStateKey)の役目ですが、reactiveで定義したステートをグローバルステートとして使いまわせるようにするために必要になります。

InjectionKey<T>に、ReturnType<T>で生成したステートの型を渡すことで、provide/injectionを使用した際に型検査が効くようになります。

todoStateKeyの型は以下のようになりました。

const todoStateKey: InjectionKey<{
    state: {
          readonly todoItems: readonly {
          readonly id: number;
          readonly done: boolean;
          readonly text: string;
      }[];
    };
    addTodo: (value: string) => void;
    removeTodo: (id: number) => void;
    toggleTodo: (id: number) => void;
}>

InjectionKeyの型はこんな感じになってます。

interface InjectionKey<T> extends Symbol {}

キーには文字列を定義することもできますが、基本的にはSymbolで定義するのが定石らしいです。

provideメソッドの引数にInjectionKeyとステートを定義したメソッドを渡す

今回はグローバルでステートを使い回せるようにしたいので、ルートコンポーネントでprovideメソッドを使用します。

この処理を行うことで、アプリケーション内の全てのコンポーネントでTODOリストのステートを参照することができるようになります。

import { provide, createApp } from 'vue';
import { todoState, todoStateKey } from './store/todo/todo';
import App from './App.vue';

createApp(App).provide(todoStateKey, todoState()).mount('#app')

provideで供給されたグローバルステートは、inject(キー)で受け取ることができるようになります。
今回のTODOアプリだとこんな感じになります。

const state = inject(todoStateKey);

注意点として、provideの記述を行う場所ですが、アプリケーションの起点になるmain.ts(main.js)か、グローバルステートとして使いまわせるようにしたいトップの要素(pageとか、layoutsとか)じゃないと正常に動作しませんでした。

具体的に発生したエラーですが、const state = inject(todoStateKey);の部分でinjectに失敗して、stateを正常に読み込んでくれませんでした。

injectを使用して、状態を読み込むという処理の関数化を行う

provide・injectパターンを用いてデータの状態を管理する場合、injectを使用して、状態を読み込むという処理を呼び出すコンポーネントで都度記述する必要があります。

以下のような、関数を定義しておくと、コンポーネント内でステートを呼び出すたびに、inject(キー)でステートを受け取って、問題なければステートを返却して、ステートが取得できない場合は例外を返すという一連の処理をステートを呼び出すたびに記述する必要がなくなります。

import { inject } from 'vue';
import { todoStateKey } from './todo';

export const useTodo = () => {
  const state = inject(todoStateKey);

  if (!state) {
    throw new Error('NO Global Key');
  }

  return state;
};

ステートを呼び出してみる

最後に、ステートを呼び出す処理を書いてみます。
TODOの登録を行う処理はこんな感じで書けます。

<template>
  <div class="todo">
    <h2>TODOを追加</h2>
    <div class="input-wrap">
      <input type="text" v-model="inputValue">
      <button @click="addTodoFunc" class="base-button">Todoを追加</button>
    </div>
    <p>入力した値: {{ inputValue }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useTodo } from '../store/todo/use-todo';
import { useInputValue } from '../composables/use-input-value';

export default defineComponent({
  name: 'TodoSampleInput',
  setup() {
    const { inputValue } = useInputValue();
    const { addTodo } = useTodo();

    const addTodoFunc = () => {
      addTodo(inputValue.value);
      // inputの中身をリセットする
      inputValue.value = '';
    }

    return {
      inputValue,
      addTodoFunc,
    };
  },
});
</script>

<style lang="scss" scoped>
.todo {
  .input-wrap {
    input {
      padding: 8px;
      border: 1px solid rgb(221, 221, 221);
      border-radius: 5px;
    }

    button {
      padding: 6px;
      border: none;
      border-radius: 5px;
      color: #fff;
      font-weight: bold;
      margin-left: 12px;
      cursor: pointer;
      background: #eb6100;
    }
  }
}
</style>

scriptの内部は、TODOを追加した後に、inputの中身をクリアする処理のみが記述されているので、データの保持や処理とビューが分離されて、コードがスッキリしているのがわかるかと思います。(なんとか感じていただけると嬉しいです)

登録したTODO一覧の表示と、TODOのチェック・削除を行う処理も書いてみます。

<template>
  <TodoSampleInput />
  <div class="todo">
    <ul v-if="state.todoItems.length">
      <li @click="toggleTodo(todo.id)" v-for="todo in state.todoItems" :key="todo">
        <div class="todo-text">
          <span v-if="todo.done" class="done">✔︎</span>
          <span>{{ todo.id }}, {{ todo.text }}</span>
        </div>
        <button @click="removeTodo(todo.id)" class="base-button">Todoを削除</button>
      </li>
    </ul>
    <p v-else>TODOが登録されていません</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useTodo } from '../store/todo/use-todo';
import TodoSampleInput from '../components/TodoSampleInput.vue';

export default defineComponent({
  name: 'TodoSample',
  components: {
    TodoSampleInput,
  },
  setup() {
    const { state, removeTodo, toggleTodo } = useTodo();

    return {
      state,
      removeTodo,
      toggleTodo,
    };
  },
});
</script>

<style lang="scss" scoped>
.todo {
  ul {
    max-width: 800px;
    margin: 20px auto;

    li {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin: 12px 0;

      button {
        padding: 6px;
        border: none;
        border-radius: 5px;
        color: #fff;
        font-weight: bold;
        margin-left: 12px;
        cursor: pointer;
        background: #2c3e50;
      }

      .todo-text {
        cursor: pointer;
        text-align: left;
        width: 85%;
        padding: 8px;

        .done {
          margin-right: 10px;
        }

        &:hover {
          background: #ddd;
          transition: all 0.4s;
        }
      }
    }
  }
}
</style>

ロジックが分離できただけではなく、親子間でステートの共有が簡単にできるようになりました。

今回は省略していますが、孫コンポーネントや、そもそも子孫関係にないようなコンポーネントでも、provide・injectを使用するとステートの共有や変更が簡単にできるようになるので、アプリケーション内で使いまわしたいステートを定義したいときにprovide・injectを使用すると保守性と可読性の高いコードが書きやすくなるのかなと、Vue初心者ながら思いました。

まとめ

  • Vue3では新しいコンポーネントの設計手法であるComposition APIが最近(2020年9月頃リリース)実装された
  • ビューとロジックが分離しやすいこと、TypeScriptとの相性がいいという特徴がある
  • provide・injectが、Composition APIで利用可能なメソッドで、グローバルステートの管理がVuexよりもシンプルに簡単にできる

おまけ

Vue3で実装されたComposition APIですが、使用して感じたことをまとめてみました。

Vue3では、setup()メソッド内はReactと大差ないくらいには型の恩恵を受けられるし、reactiveでステートを定義してあげればロジックとビューを分離できるので、かなり書きやすくなったように思えます。

今までのVueとは正直別物と考えた方がいいかもしれないです。

ただ、template内部は型の補完が効かない部分(propsの定義部分はちゃんと補完効いた)もあるので、TSとの相性という面だと、まだReactには劣っているのかなという印象です。

あまり適切な表現ではないかもしれませんが、、、Vue3・Composition API・TSの書き具合は、型がルーズなReactって感じです。