vue-composition-apiで作るカスタムHook入門〜useHoge()を自作してみよう〜
概要
vue/composition-api
の登場によって, Vue.js
でも React
の Custom Hook
のような関数を簡単に作成できるようになりました.
本記事では, vue/composition-api
で作る API
呼び出し用の Hook
として「useApi」を紹介しながら, 作成のコツや考え方を説明していきます.
また, 最後には useApi
単体でのテストコードも添付しています. Hook
のテストコードが気になる方もぜひ読んでください.
対象読者
-
vue/composition-api
の存在は知っている, または軽く使ったことがある -
React
のHook
の存在は知っている, または軽く使ったことがある -
useHoge
で表される最近のフロントエンドのコードがよくわかっていない
といったステータスの方に, useHoge
を作成する狙いと composition-api
での作り方を解説します.
フロントエンドでの非同期処理実装時の課題
まずは課題提起として, フロントエンドで非同期処理を実装するときの課題について説明します.
ここでの非同期処理は, バックエンド API との通信処理であったり, Firebase
からデータを読み取ったり書き込んだりする処理のことです.
非同期処理の実装時に, 「通信中の状態の表現」と「エラーメッセージの表示」は大抵の場合, 対応が必須となります.
具体的には, Form
の Submit
を押したときに, 通信中はボタンの連打を防ぐために 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
としてまとめておくことで, フラグ管理の漏れをふせぐことができます.
API を実行する useApi の作成
useApi の使い方
それでは, API を実行する useApi
を作成してみましょう.
useApi
と composition-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 つの値が返ってきます.
loading
と error
の値はリアクティブな値となっています. どういうことかと言いますと, これをこのまま template
の中で使用すれば, 値の変更を監視して View
を適宜書き換えてくれるようになるということです.
これまで Vue2
で data()
で返していた値と同じ扱いとなります.
<template>
<form>
<div v-if="submitLoading">{{ submitErrors.message }}</div>
<div>ここにFormの内容</div>
<button :disabled="submitLoading" @click="onSubmitClicked">送信する</button>
</form>
</template>
こんな感じです.
onSubmitClicked
の中でawait handleApi()
を実行している途中は, 勝手に submitLoading
が True
になりボタンが 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
関数を実行するとともに, その前後で適切に loading
と errors
を書き換えたりリセットしています.
最後に handleApi()
関数と, 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
という名前のライブラリがあります.
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
}
}
もし記事が参考になれば、Zenn でサポートいただければ嬉しいです。
技術書・カンファレンス等に当てさせていただきます!
Discussion