vue-composition-apiで作るカスタムHook入門〜useHoge()を自作してみよう〜

公開:2020/09/21
更新:2020/09/21
17 min読了の目安(約10700字TECH技術記事

概要

vue/composition-apiの登場によって, Vue.js でも ReactCustom Hook のような関数を簡単に作成できるようになりました.

本記事では, vue/composition-apiで作る API 呼び出し用の Hook として「useApi」を紹介しながら, 作成のコツや考え方を説明していきます.

また, 最後には useApi 単体でのテストコードも添付しています. Hook のテストコードが気になる方もぜひ読んでください.

対象読者

  • vue/composition-apiの存在は知っている, または軽く使ったことがある
  • ReactHook の存在は知っている, または軽く使ったことがある
  • useHoge で表される最近のフロントエンドのコードがよくわかっていない

といったステータスの方に, useHoge を作成する狙いと composition-api での作り方を解説します.

フロントエンドでの非同期処理実装時の課題

まずは課題提起として, フロントエンドで非同期処理を実装するときの課題について説明します.

ここでの非同期処理は, バックエンド API との通信処理であったり, Firebase からデータを読み取ったり書き込んだりする処理のことです.

非同期処理の実装時に, 「通信中の状態の表現」と「エラーメッセージの表示」は大抵の場合, 対応が必須となります.

具体的には, FormSubmit を押したときに, 通信中はボタンの連打を防ぐために Disabled にする必要があります. また, 通信の結果, エラーになってしまったら, エラーメッセージを適宜 View 内に表示してあげる必要があります.

従来の実装だと, これらの状態を保存するために別々の変数を管理する必要があり, 抜け漏れが発生してしまうリスクが有りました.

Vue2 での実装

Vue2 時代の実装でしたら, 下記のようなコードで対応していたと思います.

data() {
    return {
        submitLoading: false,
        submitErrors: null as Error | null
    }
},
methods: {
    async onSubmitClicked() {
        this.submitLoading = true

        // ※this.paramsはフォームで投稿された内容とします
        await this.$axios.$post('/api/some-form-api', this.params)
            .then(() => {
                this.$router.push(/* 送信後のページ遷移 */)
            })
            .catch((err: Error) => { // Errorの型定義は適当です
                this.submitErrors = err
            })
            .finally(() => {
                this.submitLoading = false
            })
    }
}

これでも(おそらく)動くのですが, こういった実装方針の場合, 通信処理があるたびにその Vue インスタンスでローディング中のフラグとかエラーを保持しておかないといけなくて, 漏れが発生しそうです. たとえば上記の例だと Submit に成功したらページ遷移しますが, そういった仕様ではない場合はエラー変数をクリアする処理を then 節に書いておかないといけないです.

そして, こういう状態管理は非同期処理全般で共通で書かないといけないわけですが, ページごとに同様のフラグ管理を車輪の再発明しなければいけないことが課題です. これを, useHoge という名前の Hook としてまとめておくことで, フラグ管理の漏れをふせぐことができます.

Hook のメリットは, 【ステートを持ったロジックをコンポーネントから抽出して, 単独でテストしたり, また再利用したりできる】ことであると, React Hookのページには書かれています. 本記事ではここでの"ステートをもったロジック"のうち, 非同期処理にスコープを絞って解説しています

Hook, すなわち【ステートを持ったロジックをコンポーネントから抽出した】関数は, 慣習として useHoge という命名にするようです.

API を実行する useApi の作成

useApi の使い方

それでは, API を実行する useApi を作成してみましょう.

useApicomposition-api を活用すれば, 上記のようなコードは以下のように書くことができます.

setup() {
    // ...paramsの生成は省略...

    const {
        handleApi,
        loading: submitLoading,
        error: submitErrors
    } = useApi(async () => {
        return await this.$axios.$post('/api/some-form-api', params)
    })

    const onSubmitClicked = async () => {
        await handleApi()

        if (!submitErrors) {
            this.$router.push(/* 送信後のページ遷移 */)
        }
    }

    return {
        submitLoading,
        submitErrors,
        onSubmitClicked
    }
}

composition-api では, これまで Vue2 では data(), methods, computed 等に散らばっていたパラメータを全部 setup()関数内で初期化してまとめて 1 つのオブジェクトとして return します. このこと自体は Vue3 関連の記事やニュースで話題に上がるので, ご存知の方も多いと思います.

今回の目玉は useApi() というオリジナル Hook の実行です.

こちらに API 呼び出しをする関数を渡すと, handleApi, loading, error の 3 つの値が返ってきます.

loadingerror の値はリアクティブな値となっています. どういうことかと言いますと, これをこのまま template の中で使用すれば, 値の変更を監視して View を適宜書き換えてくれるようになるということです.
これまで Vue2data()で返していた値と同じ扱いとなります.

<template>
  <form>
    <div v-if="submitLoading">{{ submitErrors.message }}</div>
    <div>ここにFormの内容</div>
    <button :disabled="submitLoading" @click="onSubmitClicked">送信する</button>
  </form>
</template>

こんな感じです.

リアクティブとは何か, って考え始めると難しいので, 要は上記のようにこれまで Vue2 でやってきたように, 変数を書き換えたら対応する View も変わるような手法のことを指すと思ってください. Vue2 では data()で返した値は自動的にリアクティブになったわけですが, composition-api 以降は, 意図的に”そういう”値があるとして扱う必要があり, TypeScript でも Ref<T>という型が明示的に与えられています. Ref<T>型の変数のみがリアクティブになるというわけですね.

onSubmitClicked の中でawait handleApi()を実行している途中は, 勝手に submitLoadingTrue になりボタンが disabled となります.
通信の結果としてエラーが返ってきたときは, submitLoading に値が入るので, エラーメッセージが View に表示されます. (※本当はエラーメッセージそのまま出すのがいいケースばかりとは限らないのですが)

このように, useApi を利用することで, ローディングとエラー内容がセットになって扱えるため, 前述のような抜け漏れのリスクを低減できます.

useApi()のソースコード

それでは, これまで説明したような利点がある useApi()のソースコードを示し, 解説していきたいと思います.

import { reactive, Ref, ref, toRefs } from '@vue/composition-api'; // Nuxt.jsを利用しているときは@nuxtjs/...を使うこと
import { AxiosError } from 'axios';

type useApiResult<T> = {
  handleApi: () => Promise<T | null>;
  errors: Ref<ApiErrorResponse | null>;
  loading: Ref<boolean>;
};

type ApiErrorResponse = {
  message: string;
  status: number;
  reason: Record<string, string>;
};

export type useApi = {
  <T>(apiFunc: () => Promise<T>): useApiResult<T>;
};

export const useApi: useApi = (apiFunc) => {
  const state = reactive<{
    errors: ApiErrorResponse | null;
    loading: boolean;
  }>({
    errors: null,
    loading: false,
  });

  const handleApi = async () => {
    state.loading = true;

    return await apiFunc()
      .then((apiResponse) => {
        state.errors = null;
        return apiResponse;
      })
      .catch((err: AxiosError) => {
        // axiosを使っている前提で書いているので要注意
        state.errors = {
          message: err.response?.data?.message,
          status: err.response?.status || 500,
          reason: err.response?.data,
        };
        return null;
      })
      .finally(() => {
        state.loading = false;
      });
  };

  return {
    handleApi,
    ...toRefs(state),
  };
};

結構長いソースコードになっております.

とはいえ型定義の割合が多いため, 要は本体部分のここのコードだけ理解していれば大丈夫です.

export const useApi: useApi = (apiFunc) => {
  const state = reactive<{
    errors: ApiErrorResponse | null;
    loading: boolean;
  }>({
    errors: null,
    loading: false,
  });

  const handleApi = async () => {
    state.loading = true;

    return await apiFunc()
      .then((apiResponse) => {
        state.errors = null;
        return apiResponse;
      })
      .catch((err: AxiosError) => {
        // axiosを使っている前提で書いているので要注意
        state.errors = {
          message: err.response?.data?.message,
          status: err.response?.status || 500,
          reason: err.response?.data,
        };
        return null;
      })
      .finally(() => {
        state.loading = false;
      });
  };

  return {
    handleApi,
    ...toRefs(state),
  };
};

やっていることはシンプルで,引数で渡ってきた関数に状態を更新する処理を追加して返しているだけです. これだけでも十分,ローディングやエラーメッセージに関する車輪の再発明を防ぐことができます.

冒頭のconst state = reactive<{...の部分では, 扱いたい状態を宣言しています. 今回 useApi ではエラーとローディング状態を扱いたいので, それらを reactive()関数で作成します.

ただのオブジェクトとして宣言せず, reactive()関数の引数として渡すことで, オブジェクト内の値が全部リアクティブに扱えるようになります.

// 以下のように書いてしまってはリアクティブになりません. 最初のViewのまま一生表示が変わらないでしょう
const state = {
  errors: null,
  loading: false,
};

state の中身のプロパティは全てリアクティブな値になっているため, const handleApi = async () => {で記述している関数の内部で, state.loading = trueを実行すれば, もともとloadingを参照している View も全部 true として扱うように表示を更新してくれます.

<template>
  <form>
    <div v-if="submitLoading">{{ submitErrors.message }}</div>
    <div>ここにFormの内容</div>
    <!-- handleApi()を実行すれば, disabled属性がTrueになります -->
    <button :disabled="submitLoading" @click="onSubmitClicked">送信する</button>
  </form>
</template>

ここで作成している handleApi()関数は, 引数として渡された apiFunc 関数を実行するとともに, その前後で適切に loadingerrors を書き換えたりリセットしています.
最後に handleApi() 関数と, state 変数をひとまとめにして返します.

細かいですが reactive()で作成したオブジェクトは...toRefs(state)のようにして返す必要があります. ここの解説は割愛します.

まとめ

本記事で例示したような useApi 関数を作ると, 非同期処理の前後で実行すべき状態の更新を関数内に閉じ込めることができ, 関数の外側からは実行したい処理だけを渡せば良くなります.

▼ 再掲

const { handleApi, loading: submitLoading, error: submitErrors } = useApi(
  async () => {
    return await this.$axios.$post('/api/some-form-api', params);
  }
);

通信の前には必ずローディングを True にして, 終わったら False にして・・・といったことを開発者が意識する必要がなくなります. useApi を実行するだけで, 適切なタイミングでフラグ管理をしてくれる handleApi 関数を手に入れることができるからです.

このように, 状態の監視や更新を伴う処理はフロントエンド, 特に SPA ではかなりの頻度で実装することになるため, 適切に汎用化した Hook を組むことでより状態を意識したソースコードが書きやすくなるでしょう.

余談 ①useSWRV

ちなみに GET リクエストだけに特化して言えば, useSWRVという名前のライブラリがあります.
https://github.com/Kong/swrv

const { data: projects, error, isValidating } = useSWRV(
  () => user.value.id && '/api/projects?uid=' + user.value.id,
  fetch
);

useSWRV が突出して素晴らしいのは, 第 1 引数に key を指定できることで, この key が変化すると再度データを GET してきてくれます.
チャット画面のように, データの取得先が何度も切り替わるようなユースケースにおいて有用です.

第 2 引数には, 本記事と同じようにデータを読み取ってくる関数を指定すると良いです.

余談 ② テストコード

useApi 単体のテストコードも書くことができます. 以下のソースコードをご参照ください. jest です.

このように, 状態管理の部分だけ切り出して, 必要に応じてテストコードを書くことができるのも composition-api を使って Hook に切り出すメリットです.

import { useApi } from '@/utils/api/useApi';

describe('API呼び出しヘルパのテスト', () => {
  describe('正常系', () => {
    const apiFunc = () => new Promise((resolve) => resolve(true));

    const { handleApi, errors, loading } = useApi(apiFunc);

    it('初期値', () => {
      expect(errors.value).toBe(null);
      expect(loading.value).toBe(false);
    });

    it('loading', () => {
      handleApi();
      expect(loading.value).toBe(true);
    });

    it('loading done', async () => {
      const response = await handleApi();
      expect(loading.value).toBe(false);
      expect(response).toBe(true);
    });
  });
  describe('異常系', () => {
    // eslint-disable-next-line promise/param-names,prefer-promise-reject-errors
    const apiFunc = () =>
      new Promise((_, reject) =>
        reject({
          response: {
            data: {
              message: 'message',
            },
          },
        })
      );

    const { handleApi, errors, loading } = useApi(apiFunc);

    it('loading', () => {
      handleApi();
      expect(loading.value).toBe(true);
    });

    it('loading done', async () => {
      const response = await handleApi();
      expect(loading.value).toBe(false);
      expect(response).toBe(null);
      expect(errors.value.message).toBe('message');
    });
  });
});

余談 ③ レイヤーごとにオリジナル Hook を作ろう

ちなみに本記事で提示した実装例はまだ良くすることができます. 以下のように, フォームのパラメータやクリックハンドラも含めて全部 useHoge に収めてしまうという発想です.

抽象的な Hook と, ページごとのユースケースに沿った Hook を作ってレイヤーに分けて扱うと設計がしやすいですし, テストも書けます.

export type useXXXXForm = () => {
    const state = reactive({
        name: '',
        email: '',
    })

    const params = computed(() => {
        return {
            ...state
        }
    })

    const {
        handleApi,
        loading: submitLoading,
        error: submitErrors
    } = useApi(async () => {
        return await this.$axios.$post('/api/some-form-api', params.value)
    })

    const onSubmitClicked = async () => {
        await handleApi()

        if (!submitErrors) {
            this.$router.push(/* 送信後のページ遷移 */)
        }
    }

    return {
        ...toRefs(state),
        submitLoading,
        submitErrors,
        onSubmitClicked
    }
}