🐤

App Router用fetcherの雛形を考えてみた

2023/05/21に公開

こんにちは!りょたです。
https://twitter.com/Ryo54388667

Next.js13 App Router用のfetcherの雛形を作成してみたので紹介します。いわゆる「俺俺フェッチャー」ってやつです。

前置き

まず、Next.js13からレンダリングタイプについて、fetchメソッドで制御できるようになりました。Next.jsのドキュメントを参考に、するとSG,SSR,ISRの指定は以下のようなインターフェースとなります。

export default async function Page() {
  // This request should be cached until manually invalidated.
  // Similar to `getStaticProps`.
  // `force-cache` is the default and can be omitted.
  const staticData = await fetch(`https://...`, { cache: 'force-cache' });
 
  // This request should be refetched on every request.
  // Similar to `getServerSideProps`.
  const dynamicData = await fetch(`https://...`, { cache: 'no-store' });
 
  // This request should be cached with a lifetime of 10 seconds.
  // Similar to `getStaticProps` with the `revalidate` option.
  const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
  });
 
  return <div>...</div>;
}

必要な部分だけ抜き出すと、

//従来のgetStaticPropsの替わり
const staticData = await fetch(`https://...`, { cache: 'force-cache' });
//従来のgetServerSideProps替わり
const dynamicData = await fetch(`https://...`, { cache: 'no-store' });
//従来のrevalidate関数替わり
const revalidatedData = await fetch(`https://...`, {
    next: { revalidate: 10 },
});

このようなfetchのoptionを利用して、レンダリングタイプを指定するようになりました。
アプリケーションのなかで、データフェッチを行う機会は非常に多いです。そんな中、そのたびに「fetchメソッドを書いて、その後に、cacheオプションを書いて、値を指定して。。。」と、このように考えるのは不便で仕方がありません。というわけで、これを解決するためには、雛形となるようなフェッチャーを作成するのが良いのではないかと思います。

続いて、満たすべき仕様について考えました。

  • 第一引数でAPIエンドポイント(URL)を渡せること。
  • レスポンスされたデータに型をつけられること。
  • ISRのメソッドの時はrevalidateの値(frequency)を渡すようにすること。

このような仕様が必要ではないかと思います。
これらを満たすように、雛形を作成していきます。

完成するまでの試行錯誤

「プロセスはいいから、結論を教えてくれ!」という方はこちらのリンクで下部に飛んでください。

まず、第一号を紹介します。簡略化するために、一旦エラーハンドリングは抜きにしています。
このような形になりました!

type RENDERING_TYPE = "SG" | "SSR" | "ISR";

const baseFetcher =
  (renderingType: RENDERING_TYPE) =>
  async (url: string, frequency?: number) => {
    let options: RequestInit = {};

    switch (renderingType) {
      case "SG":
        options = { cache: "force-cache" };
        break;
      case "SSR":
        options = { cache: "no-store" };
        break;
      case "ISR":
        options = { next: { revalidate: frequency } };
        break;
      default:
        break;
    }
    const response = await fetch(url, options);
    const data = await response.json();
    return data;
  };

export const fetchDataWithSG = baseFetcher("SG");
export const fetchDataWithSSR = baseFetcher("SSR");
export const fetchDataWithISR = baseFetcher("ISR");

今、振り返ると残念な感じが否めません。。💦
いくつか問題点があります。レスポンスされたデータに型がついていないことと、レンダリングタイプがISRのときもfrequencyがオプショナル指定になってしまっています。

そこで、typescriptのオーバーロード(参考記事)を利用して、レンダリングタイプがISRのとき、frequencyを必須にするように修正しました。

type Fetcher = {
  (renderingType: "SG" | "SSR", url: string): Promise<any>,
  (renderingType: "ISR", url: string, frequency: number): Promise<any>,
}

const baseFetcher: Fetcher =...()

export const fetchDataWithSG = (url: string) => baseFetcher("SG", url);
export const fetchDataWithSSR = (url: string) => baseFetcher("SSR", url);
export const fetchDataWithISR = (url: string, frequency: number) => baseFetcher("ISR", url, frequency);

これで、レンダリングタイプがISRのとき、frequencyが必須となるような関数を作成できました。

あとは、typescriptのジェネリクスを利用して、型を引数として渡せるように修正します。

type Fetcher = {
  <T>(renderingType: "SG" | "SSR", url: string): Promise<T>,
  <T>(renderingType: "ISR", url: string, frequency: number): Promise<T>,
}

const baseFetcher: Fetcher =
  async <T>(renderingType, url, frequency?) => {()}
  
export const fetchDataWithSG = <DataType>(url: string) => baseFetcher<DataType>("SG", url);
export const fetchDataWithSSR = <DataType>(url: string) => baseFetcher<DataType>("SSR", url);
export const fetchDataWithISR = <DataType>(url: string, frequency: number) => baseFetcher<DataType>("ISR", url, frequency);

VScode上で確認しても、問題なく利用できそうです。

ISRを指定する時は、frequencyを必須にすることもできました。

これで、当初やりたかった雛形を作成することができました!✨

📍full code

エラーハンドリングとoptionを引数に入れるように修正したものを載せておきます。

type RENDERING_TYPE = "SG" | "SSR" | "ISR";

type Fetcher = {
  <T>(
    renderingType: "SG" | "SSR",
    url: string,
    headers: RequestInit | undefined
  ): Promise<T>;
  <T>(
    renderingType: "ISR",
    url: string,
    headers: RequestInit | undefined,
    frequency: number
  ): Promise<T>;
};

const baseFetcher: Fetcher = async <T>(
  renderingType: RENDERING_TYPE,
  url: string,
  headers: RequestInit | undefined,
  frequency?: number
) => {
  let options: RequestInit = headers ? { ...headers } : {};

  switch (renderingType) {
    case "SG":
      options = { ...options, cache: "force-cache" };
      break;
    case "SSR":
      options = { ...options, cache: "no-store" };
      break;
    case "ISR":
      if (frequency === undefined) {
        throw new Error("🔥: frequencyを指定してください。");
      }
      options = { ...options, next: { revalidate: frequency } };
      break;
    default:
      throw new Error(`🔥: renderingTypeに誤りがあります。 ${renderingType}`);
  }

  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`🔥: status200以外です: ${response.status}`);
  }
  const data = (await response.json()) as T;
  return data;
};

export const fetchDataWithSG = <DataType>(url: string, headers?: RequestInit) =>
  baseFetcher<DataType>("SG", url, headers);
export const fetchDataWithSSR = <DataType>(
  url: string,
  headers?: RequestInit
) => baseFetcher<DataType>("SSR", url, headers);
export const fetchDataWithISR = <DataType>(
  url: string,
  frequency: number,
  headers?: RequestInit
) => baseFetcher<DataType>("ISR", url, headers, frequency);

まだまだ詰めの甘いところがあると思いますので、アドバイスお願いします!

おまけ

スキーマによる型チェックをしたかったので、zodを用いたフェッチャーも掲載しておきます。

import { z } from "zod";

type RENDERING_TYPE = "SG" | "SSR" | "ISR";

type Fetcher = {
  <T>(
    renderingType: "SG" | "SSR",
    url: string,
    schema: z.ZodSchema<T>,
    headers: RequestInit | undefined
  ): Promise<T>;
  <T>(
    renderingType: "ISR",
    url: string,
    schema: z.ZodSchema<T>,
    headers: RequestInit | undefined,
    frequency: number
  ): Promise<T>;
};

const baseFetcher: Fetcher = async <T>(
  renderingType: RENDERING_TYPE,
  url: string,
  schema: z.ZodSchema<T>,
  headers?: RequestInit,
  frequency?: number
) => {
  let options: RequestInit = headers ? { ...headers } : {};

  switch (renderingType) {
    case "SG":
      options = { ...options, cache: "force-cache" };
      break;
    case "SSR":
      options = { ...options, cache: "no-store" };
      break;
    case "ISR":
      if (frequency === undefined) {
        throw new Error("🔥: frequencyを指定してください。");
      }
      options = { ...options, next: { revalidate: frequency } };
      break;
    default:
      throw new Error(`🔥: renderingTypeに誤りがあります。 ${renderingType}`);
  }

  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`🔥: status200以外です: ${response.status}`);
  }
  const rawData = await response.json();
  const data = await schema.parse(rawData);
  return data;
};

export const fetchDataWithSG = <DataType>(
  url: string,
  schema: z.ZodSchema<DataType>,
  headers?: RequestInit
) => baseFetcher("SG", url, schema, headers);
export const fetchDataWithSSR = <DataType>(
  url: string,
  schema: z.ZodSchema<DataType>,
  headers?: RequestInit
) => baseFetcher<DataType>("SSR", url, schema, headers);
export const fetchDataWithISR = <DataType>(
  url: string,
  schema: z.ZodSchema<DataType>,
  frequency: number,
  headers?: RequestInit
) => baseFetcher("ISR", url, schema, headers, frequency);


//呼び出し方
const getTodoList = async () => {
  const TodoSchema = z.object({
    userId: z.number(),
    id: z.number(),
    title: z.string(),
    completed: z.boolean(),
  });

  const data = await fetchDataWithSG<Todo[]>(
    "https://jsonplaceholder.typicode.com/todos",
    TodoSchema.array()
  );
  return data;
};

最後まで読んでいただきありがとうございます。

気ままにつぶやいているので、気軽にフォローをお願いします!

https://twitter.com/Ryo54388667

Discussion