Inside of LOVOT

GROOVE X 技術ブログ

ReactカスタムフックでAPI呼び出しを共通化してみた

GROOVE X クラウドチームのmineoです。

弊社ではLOVOTという家庭用ロボットを開発しており、クラウドチームでは機体と通信するサーバーの管理や他サービスとの連携などで、LOVOTをサポートしています。

また、LOVOTに関わるWebアプリケーションをReactで開発し、社内外(社外は一部協力会社)に向けて提供していたりもしています。

tech.groove-x.com

この記事では、直近で新規でWebアプリを開発する際に、自社APIの呼び出し部分をカスタムフックでいい感じにまとめることができたので、ご紹介します。

前提

  • React 18
  • TypeScript
  • Firebase Authentication

APIはFirebase Authenticationで認証します。idTokenをBearerトークンで利用するやりかたです。 Reactでは、react-firebase-hooksというfirebaseの機能をカスタムフックで利用できるようにしてくれたライブラリがあるので、こちらも活用します。

github.com

なるべく、標準APIを使うようにしており、axiosやquerystringsは使わず、fetch APIやURL SearchParamsなどを利用しました。

また、記事中のコードは記事用に書き下したため、実際に使っているコードとは異なる部分があります。

すなおにかくと

カスタムフックを使わずにベタに書くとどういった感じになるのか、みてみます。

以下は、ItemというリソースをGETして表示するサンプルです。

export default function Item() {
  const [item, setItem] = useState<Item>();
  const [user] = useIdToken(auth);

  useEffect(() => {
    const fetchItem = async () => {
      const idToken = await user?.getIdToken();

      const item = await fetch(`${baseUrl}/item`, {
        headers: { Authorization: `Bearer: ${idToken}` },
      }).then((response) => response.json());

      setItem(item);
    };

    fetchItem();
  }, [user]);

  return <div>{JSON.stringify(item)}</div>;
}

ここでFirebaseのidTokenを取得し、

const idToken = await user?.getIdToken();

取得したtokenを利用してfetchでGETのAPIを呼び出しています。

const item = await fetch(baseUrl, {
  headers: { Authorization: `Bearer: ${idToken}` },
}).then((response) => response.json());

この書き方でも、APIを呼び出しているのがこのコンポーネントだけなら、特に問題はありません。 ただし、複数のコンポーネントで呼び出す場合に、毎回このように直接APIを呼び出すロジックを書いてしまうのは、あまりよろしくないです。

プレゼンテーション層と密結合となっていて、テストもしにくいですし、見通しも悪く、同じようなロジックが随所に散らばってしまいます。

まずはヘッダー生成部分など、対象のAPI群を利用するのに共通のプリミティブな部分を抽出していきます。

カスタムフック:useApiClientの実装

以下のようにuseApiClientというカスタムフックを実装しました。

このuseApiClientでは、GETメソッドに対応したgetという関数を提供しています。 (同じ要領でPOSTメソッドなども追加することが可能です)

getは、汎用的に使えるようにqueryや返り値の方を指定可能にしました。

export const useApiClient = () => {
  const baseUrl = useBaseUrl();
  const [user] = useIdToken(auth);

  const createAuthorizationHeader = useCallback(async (): Promise<
    Record<string, string>
  > => {
    if (!user) throw Error("user is not signed in");
    return { Authorization: `Bearer ${await user.getIdToken()}` };
  }, [user]);

  const get = useCallback(
    async <T>(path: string, query?: Record<string, string>): Promise<T> => {
      const queryString = query && Object.keys(query).length
        ? "?" + new URLSearchParams(query).toString()
        : "";

      const result = await fetch(`${baseUrl}${path}${queryString}`, {
        headers: await createAuthorizationHeader(),
      }).then((response) => response.json());

      return result;
    },
    [baseUrl, createAuthorizationHeader]
  );

  return { get };
};

createAuthorizationHeaderは、FirebaseのidTokenを使ったheaderの情報を組み立てるプライベートな関数です。 useCallbackで囲っているのは、不必要なレンダリングや無限ループを避けるためです。

カスタムフックで関数を提供する場合は、基本的にuseCallbackで包んだほうが無難かと思います。

  const createAuthorizationHeader = useCallback(async (): Promise<
    Record<string, string>
  > => {
    if (!user) throw Error("user is not signed in");
    return { Authorization: `Bearer ${await user.getIdToken()}` };
  }, [user]);

肝心のget関数を見ていきます。

createAuthorizationHeaderと同様にuseCallbackで囲んでいます。 引数でpathとqueryを指定することで、GETメソッドでAPIを呼び出せます。

  const get = useCallback(
    async <T>(path: string, query?: Record<string, string>): Promise<T> => {

引数で受け取ったqueryのobjectは、new URLSearchParams でURLに付与する文字列に変換します。 型を Record<string, string> としていたのは、URLSearchParams のコンストラクタの型に合わせるためです。

URLSearchParamsは少し不便な点があり、配列を渡してquery文字列を生成することができません。(例えば、q=1&q=2 のような形式です) もし、配列を渡せるようにしたければ、querystringsなどのpackageを使いましょう。

URLSearchParamsで生成された文字列には、 ? が含まれないため、queryが渡されない場合には、? を付与しないようにしています。

      const queryString = query && Object.keys(query).length
        ? "?" + new URLSearchParams(query).toString()
        : "";

fetch APIを利用して、GETのAPIを呼び出しています。 baseUrlと引数で渡されたpathと変換したquerystringを連結して、URLとして扱っています。

headersでは、createAuthorizationHeader を直接使っています。

      const result = await fetch(`${baseUrl}${path}${queryString}`, {
        headers: await createAuthorizationHeader(),
      }).then((response) => response.json());

もし、POSTなどで、別のheaderを追加したければ(Content-Type: application/jsonなど)、以下のようにしましょう。

        headers: {
          ...await createAuthorizationHeader(),
          "Content-Type": "application/json",
        },

このuseApiClientを使って、最初のコードを書き換えると以下のようになります。

export default function Item() {
  const [item, setItem] = useState<Item>();
  const { get } = useApiClient();

  useEffect(() => {
    const fetchItem = async () => {
      const item = await get<Item>("/item");
      setItem(item);
    };

    fetchItem();
  }, [get]);

  return <div>{JSON.stringify(item)}</div>;
}

かなりスッキリしてきたと思います。 useApiClientを使うことで、headerやquery(今回は使っていませんが)の組み立てに悩まされることはなくなります。

これだけでもだいぶマシにはなりましたが、useApiClientは、あくまでAPI群で共通する処理を抽出しただけにすぎません。

Itemを呼び出すAPIに対応したカスタムフックを作成して、以下のように利用できたほうが、さらにベターかと思います。

const item = useItem()

カスタムフック:useItemの実装

useItemの実装は以下のようになります。 useEffectを利用することで、このカスタムフックが最初に呼ばれたときにItemを返すようにしています。

export const useItem = () => {
  const [item, setItem] = useState<Item>();

  const { get } = useApiClient();

  useEffect(() => {
    const fetchItem = async () => {
      const item = await get<Item>("/item");
      setItem(item)
    }

    fetchItem()
  }, [get])

  return item;
};

このカスタムフックがあればどのコンポーネントでも

const item = useItem();

とするだけでitemを取得することができます。

また、ここでは使用していませんが、カスタムフックの引数とuseEffectの依存にqueryを追加することで、queryに応じた結果を返すことも可能です。

loadingとerrorの返却

ここまでで実装したuseItemカスタムフックでItemを取得することはできるようになりました。しかし、loadingやerrorについては考慮されていません。 それらの情報も同時に返すようにuseItemを書き換えます。

export const useItem = () => {
  const [error, setError] = useState<Error>();
  const [loading, setLoading] = useState<boolean>(false);

  const [item, setItem] = useState<Item>();

  const { get } = useApiClient();

  useEffect(() => {
    const fetchItem = async () => {
      setLoading(true);
      setError(undefined);

      try {
        const item = await get<Item>("/item");
        setItem(item);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }

    fetchItem()
  }, [get])

  return { item, loading, error };
};

itemに加えてloadingとerrorもuseStateで状態管理をして、返却するようにしました。 これで、利用側でもloadingやerrorを意識したコンポーネントを実装することができます。

おわりに

クラウドチームでは、Reactも含めたWebの技術を多く活用しています。 一緒にLOVOTを成長させていくメンバーを絶賛募集中ですので、少しでも興味ある方はお気軽にお話しましょう!

recruit.jobcan.jp