🔥

SWRを使って型定義されたhooksを書いてみる(POST, PUT, DELETE編)

2024/06/28に公開

こんにちは。
最近暑くてホットコーヒーが飲めない季節になってきたなぁと感じています。8zkです。

この記事の前にGET編も書きましたので、よければそちらから見ていただくとわかりやすいかと思います。
というわけで今回は採用理由SWRとはは割愛させていただきます。

前回と同様にfetcherが必要なので以下のように定義します。

// /src/helpers/fetcher.ts
import axios from 'axios';

export const fetcher = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 10000,
  headers: { 'X-Custom-Header': 'foobar' },
});

axios.createを使ってaxiosのinstanceを作成します。ここではサービス全体で利用するbaseURLtimeoutを設定します。

次は作成したfetcheruseSWRMutationを使って各endpointのhooksの元となる実装を作成します。
今回はPOST, PUT, DELETE用です。

データ更新の下準備

// /src/hooks/api/useAPI.ts
import useSWRMutation from 'swr/mutation';
import { AxiosRequestConfig, AxiosError } from 'axios';
import { fetcher } from '@/helpers/fetcher';

const mapUrlParams = (url: string, urlParams?: Record<string, string>) => {
  if (urlParams) {
    return url.replace(/{([^}]+)}/g, (_, p1) => urlParams[p1]);
  }
  return url;
};

export const useMutation = <
  Request extends {} = {},
  Response extends {} = {},
  UrlParams extends {} = {},
>(
  url: string,
  axiosConfig?: AxiosRequestConfig
) => {
  return useSWRMutation<
    Response,
    AxiosError,
    string,
    Request & { urlParams?: UrlParams }
  >(url, (url, fetcherOptions) => {
    const {
      arg: { urlParams, ...restParams },
    } = fetcherOptions;
    const mappedUrl = mapUrlParams(url, urlParams);

    return fetcher
      .request({
        url: mappedUrl,
        params: restParams,
        ...axiosConfig,
      })
      .then((res) => {
        return res.data;
      });
  });
};

今回の実装のキモはmapUrlParamsです。
PUT、DELETEではurlの中に動的なparameterが含まれることがよくあります。それはリソースの更新を行うときにid情報から対象を見つけるためです。例としては/posts/1のようなendpointです。
今回作成したuseMutationは引数にurlを取りますが、urlの中に動的なparameterが含まれるため、以下のようなジレンマが発生します。

const { trigger } = useMutation('/posts/1'); // 1の部分は動的にしたい!

このようなことがよく起こるかと思います。

なのでuseMutation/posts/{postId}としてurlを渡し、triggerurlParamsとしてpostIdが設定できるようにしました。
そのときに利用するのがmapUrlParamsです。
この関数はurlParamsに含まれるkey(postId)がurlに含まれる{postId}と一致した場合にそこをurlParamsのvalueに置き換える正規表現を行います。

結果的にtrigger({ urlParams: { postId: 1 }})がendpointとして/posts/1になるのです。

データの更新

import { useMutation } from './useAPI';

type UpdatePostRequest = {
  body: string;
  title: string;
  userId: number;
};

type UpdatePostUrlParams = {
  postId: string;
};

type UpdatePostResponse = {
  id: number;
};

export const useUpdatePost = () => {
  return useMutation<
    UpdatePostRequest,
    UpdatePostResponse,
    UpdatePostUrlParams
  >('/posts/{postId}', {
    method: 'PUT',
  });
};

先ほど作成したuseMutationを使って型定義を行ないます。
UpdatePostRequestUpdatePostUrlParamsUpdatePostResponseの型を定義し、それをuseMutationに渡すことでuseUpdatePostは型定義されたdatatriggerを利用することができます。

componentでは以下のように利用します。
useMutationを利用しているためdatatriggerだけでなくerrorisMutatingresetをhooksから利用できます。これらもサービスに応じてハンドリングしてください。

// /src/routes/Home/index.tsx
import { AxiosError } from 'axios';
import { useUpdatePost } from '@/hooks/api/usePost';

export const Home = () => {
  const { trigger: updatePost, isMutating: isUpdatePostMutating } =
    useUpdatePost();

  return (
    <div>
      <h1>Home</h1>
      <button
        disabled={isUpdatePostMutating}
        onClick={async () => {
          try {
            await updatePost({
              body: 'body',
              title: 'title',
              userId: 1,
              urlParams: { postId: '1' },
            });
          } catch (e: unknown) {
            if (e instanceof AxiosError) {
              console.error(e.message);
            }
          }
        }}
      >
        Update Post
      </button>
    </div>
  );
};

このようにcomponent側で型定義されたSWRのhooksを利用できるようになりました。

前回のGET編も合わせると以下になります。

// /src/hooks/api/useAPI.ts
import useSWR from 'swr';
import useSWRMutation from 'swr/mutation';
import { AxiosRequestConfig, AxiosError } from 'axios';
import { fetcher } from '@/helpers/fetcher';

const mapUrlParams = (url: string, urlParams?: Record<string, string>) => {
  if (urlParams) {
    return url.replace(/{([^}]+)}/g, (_, p1) => urlParams[p1]);
  }
  return url;
};

// For query(only supports 'GET') request
export const useQuery = <Request extends {} = {}, Response extends {} = {}>(
  url: string,
  params?: Request,
  axiosConfig?: AxiosRequestConfig
) => {
  const key = [url, JSON.stringify(params)];

  return useSWR<Response, AxiosError>(key, () =>
    fetcher
      .request({
        url,
        method: 'GET',
        params,
        ...axiosConfig,
      })
      .then((res) => res.data)
  );
};

// For mutation(supports 'POST', 'PUT', 'DELETE') request
export const useMutation = <
  Request extends {} = {},
  Response extends {} = {},
  UrlParams extends {} = {},
>(
  url: string,
  axiosConfig?: AxiosRequestConfig
) => {
  return useSWRMutation<
    Response,
    AxiosError,
    string,
    Request & { urlParams?: UrlParams }
  >(url, (url, fetcherOptions) => {
    const {
      arg: { urlParams, ...restParams },
    } = fetcherOptions;
    const mappedUrl = mapUrlParams(url, urlParams);

    return fetcher
      .request({
        url: mappedUrl,
        params: restParams,
        ...axiosConfig,
      })
      .then((res) => {
        return res.data;
      });
  });
};
// /src/hooks/api/post.ts
import { useQuery, useMutation } from '@/hooks/api/useAPI';

type Post = {
  id: number;
  body: string;
  title: string;
  userId: number;
};

type FetchPostsResponse = Post[];

type FetchPostResponse = Post;

type UpdatePostRequest = {
  body: string;
  title: string;
  userId: number;
};

type UpdatePostUrlParams = {
  postId: string;
};

type UpdatePostResponse = {
  id: number;
};

type DeletePostUrlParams = {
  postId: string;
};

type CreatePostRequest = {
  body: string;
  title: string;
  userId: number;
};

type CreatePostResponse = Post;

export const useFetchPosts = () => {
  return useQuery<{}, FetchPostsResponse>(`/posts`, {});
};

export const useFetchPost = (postId: string) => {
  return useQuery<{}, FetchPostResponse>(`/posts/${postId}`, {});
};

export const useUpdatePost = () => {
  return useMutation<
    UpdatePostRequest,
    UpdatePostResponse,
    UpdatePostUrlParams
  >('/posts/{postId}', {
    method: 'PUT',
  });
};

export const useDeletePost = () => {
  return useMutation<{}, {}, DeletePostUrlParams>('/posts/{postId}', {
    method: 'DELETE',
  });
};

export const useCreatePost = () => {
  return useMutation<CreatePostRequest, CreatePostResponse>('/posts', {
    method: 'POST',
  });
};

以上でGET, POST, PUT, DELETEができるhooksが完成しました。

私はSWRを触っていて漠然とサンプルを眺めていてもどのようにプロダクションで利用するかイメージが湧きませんでしたが、実際に触ってみてプロダクションでも利用できるようなものが作れたんじゃないかなと思います(あくまで希望です)。
これが誰かの参考になったら嬉しいです。
まだまだ型が弱い箇所(特にuseMutationurlParams周り)もありますが、今後も改善を続けていきたいと思います。

最後に

スペースマーケットでは一緒に働く仲間を募集しています!
軽く話を聞いてみたいなという方も大歓迎ですので、興味を持っていただけた方はご応募お待ちしています!

スペースマーケット Engineer Blog

Discussion