next/link の Prefetching と型安全
本記事は前回投稿「Next.js の API Routes から SWR の型推論を導く」の続編であり、事前にご覧いただいている前提で解説を一部簡略化しています。あらかじめご了承ください。動くサンプルは以下のブランチにアップしています。
React Location の Prefetching と比較
routing ライブラリ React Location は、React Query と併用することで Routing / Prefetching / Caching が連携し、In-Memory の Client Cache を効率的に取得することができます。React Location ではライブラリが提供する<Link>
コンポーネントを使用しますが、当然 Next.js のファイルシステム routing や<Link>
コンポーネントの競合になるので、Next.js と相性は良くありません。
先日の Next.js 勉強会でもこのトピックを取り上げたように、SPA の Navigation をさらに速くするアプローチとして、Prefetching は一層着目されると予想されます。
個人的には Vercel 製ライブラリの SWR に、この観点で今後改善があることを期待しています(Next.js に組み込まれてはいないため、しばらくは期待できないと思いますが…)現状では「React Location & React Query」が実現する Prefetching 相当の機能を実現するためには自前で用意しなければいけません。そこで、今回実験として <Link>
コンポーネント拡張に挑戦してみました。
next/link の課題
本稿で解決していく next/link の課題は次のとおりです。
- ファイルシステム routing となっており型安全ではない
- pathpida など、サードパーティライブラリで補強する必要がある
- Prefetch が ISR 向けで、User Specific なデータは事前取得できない(CSR 向けではない)
- In-Memory の Client Cache 機構(SWR など)と連動していない
- SWR などと連携し、手動で mutate を実行する必要がある
これら課題解決のため、できあがったものが以下の<Link>
コンポーネントです。通常のnext/link
挙動を残しつつ、props を拡張しました。SWR の Programmatically Prefetch を、next/link
の ISR Prefetch と同様に Intersection Observer・マウスオーバーで発火しています。
<Link
path="/users/[id]" // リンク先 page の path文字列
query={{ id: user.id }} // リンク先 page の PathPram/QueryParam
swrPrefetch={{
// 組み込みの prefetch と同タイミングで実行する SWR の Programmatically Prefetch (mutate)
path: "/api/users/[id]", // API Routes に実装している API への path文字列
query: { id: user.id }, // API Routes リクエストに与える PathPram/QueryParam
}}
>
{user.name}
</Link>
前回投稿と同様に、型安全を導くのは固定の path 文字列です。Pages path 文字列 / API Routes path 文字列を識別、引数として選択することで、各々対応する query を絞り込むことが可能になっています。
Pages 型を自動生成する
Next.js のルーティングは、モジュールシステム観点からは透過的参照がないため、TypeScript の型推論と相性が悪いです。この課題は前回投稿内容と同様に「実装ファイルに定義した型への参照」を繋ぐことで解決可能です。
npm run gen:apitype
で、型定義への参照型をsrc/types/pages
に自動生成します。生成された型定義は、module 宣言空間"@/types/pages"
においてPages
型定義の宣言結合が発生します。このPages
型のプロパティ名称が、ファイルシステム routing の path 文字列相当になります。
// src/types/pages/users/[id].d.ts
declare module "@/types/pages" {
interface Pages {
"/users/[id]": Query & {
[k in "id"]: string;
};
}
}
// src/types/pages/articles/[id]/index.d.ts
declare module "@/types/pages" {
interface Pages {
"/articles/[id]": {
[k in "id"]: string;
};
}
}
// src/types/pages/articles/[id]/details/[detail].d.ts
declare module "@/types/pages" {
interface Pages {
"/articles/[id]/details/[detail]": {
[k in "id" | "detail"]: string;
};
}
}
"/articles/[id]/details/[detail]"
といった文字列をランタイムで asPath 相当に置換する処理を挟むため、"id"
および"detail"
の値が必要になります。ここで生成している MappedType は、その Query を要求するために出力されています。Optional Query が必要な場合、既定名称Query
で型定義を page から export。自動生成時に収集され Intersection Type の一部になります。
// src/pages/users/[id].tsx
export type Query = PageQuery<"from">;
Page 間遷移を型安全にする
参照を繋ぐ型ができたので、このプロジェクト内で使用する<Link>
コンポーネントを拡張します。③ で行っている処理の挙動は、こちらのテストの通りです。以上で内部リンクにおける遷移が型安全になりました。
// src/components/Link.tsx
import type { Pages } from "@/types/pages";
import NextLink from "next/link";
import { mapPathParamFromQuery } from "@/utils/mapPathParamFromQuery";
// ① href 相当文字列は ③ で内部構築するため、props として受け付けない
type LinkBase = Omit<React.ComponentPropsWithoutRef<typeof NextLink>, "href">;
// ② 宣言結合された`Pages`型を Lookup に利用
export function Link<
PagePath extends keyof Pages,
PageQuery extends Pages[PagePath]
>({
path,
query,
children,
...props
}: LinkBase & {
path: PagePath;
query?: PageQuery;
}) {
// ③ `[id]`など PathParam 相当の文字列を内部で置換
const href = mapPathParamFromQuery(path, query);
return (
<NextLink {...props} href={href}>
<a>{children}</a>
</NextLink>
);
}
Programmatically Prefetch を加える
SWR で Prefetch を行う場合、アプローチがいくつかあります。
以下の前提条件で解説を進めていきます。
- 公式ドキュメントで紹介されているとおり、グローバルミューテートを用います
- SWR が接続する API は、API Routes に限定しています
prefetchApiData
過度な prefetch は API サーバーの負荷を高めます。そのため、必要最小限の prefetch に留めるよう工夫することが大事です。<Link>
コンポーネントが画面に表示されたり・マウスオーバーする度にデータを取得していては、必要最小限の取得とはいえません。この課題に対応するため、今回独自に cache 済みのデータをいつ取得したのかを管理するモジュールを追加しました。
// src/utils/swr.ts
import type { GetReqBody, GetReqQuery, GetResBody } from "@/types/pages/api";
import { getApiData } from "@/utils/fetcher";
import { mapPathParamFromQuery } from "@/utils/mapPathParamFromQuery";
import { mutate } from "swr";
const defaultRevalidate = 24 * 60 * 60;
const prefetchTimestamp = new Map<string, number>();
export function clearPrefetchTimestamp() {
prefetchTimestamp.clear();
}
// Api Routes の型を Lookup する
export function prefetchApiData<
ApiPath extends keyof GetResBody,
ReqQuery extends GetReqQuery[ApiPath],
ResBody extends GetResBody[ApiPath],
ReqBody extends GetReqBody[ApiPath]
>({
path,
revalidate,
query,
requestInit,
}: {
path: ApiPath;
revalidate?: number;
query?: ReqQuery;
requestInit?: Omit<RequestInit, "body"> & { body?: ReqBody };
}): Promise<ResBody | void> {
// revalidate 秒数の指定を受け付ける (デフォルトは 1日)
const r = revalidate ?? defaultRevalidate;
if (r < 1) throw new Error("invalid revalidate value.");
// revalidate 秒数が過ぎていた場合、Prefetch を再試行する
const url = mapPathParamFromQuery(path, query);
const now = Date.now();
const timestamp = prefetchTimestamp.get(url);
const shouldPrefetch = !timestamp ? true : timestamp - (now - r * 1000) < 0;
if (!shouldPrefetch) return Promise.resolve();
prefetchTimestamp.set(url, now);
// Programmatically Prefetch を実行する
return mutate(url, () => getApiData(path, { query, requestInit }), false);
}
useApiPrefetch
<Link>
コンポーネント本来の挙動と同様に、Intersection Observer・マウスオーバーを契機に先の prefetchApiData
を実行する hooks を作ります。next/link では useIntersection という custom hooks を内部で利用していますが、アプリケーションコードで利用する想定のものではないはずなので、今回は react-intersection-observer というライブラリで実装しました。
// src/utils/swr.ts
import type { Pages } from "@/types/pages";
import type { GetReqBody, GetReqQuery, GetResBody } from "@/types/pages/api";
import { prefetchApiData } from "@/utils/swr";
import { useRouter } from "next/dist/client/router";
import React from "react";
import { useInView } from "react-intersection-observer";
export function useApiPrefetch<
PagePath extends keyof Pages,
ApiPath extends keyof GetResBody,
ReqQuery extends GetReqQuery[ApiPath],
ReqBody extends GetReqBody[ApiPath]
>({
ignoreRoute,
...options
}: {
path: ApiPath;
revalidate?: number;
query?: ReqQuery;
requestInit?: Omit<RequestInit, "body"> & { body?: ReqBody };
ignoreRoute?: PagePath;
}) {
const { pathname } = useRouter();
const { ref, inView } = useInView({
rootMargin: "200px",
});
const prefetch = React.useCallback(
() => prefetchApiData(options),
// eslint-disable-next-line
[]
);
React.useEffect(() => {
if (!inView || ignoreRoute === pathname) return;
prefetchApiData(options);
// eslint-disable-next-line
}, [inView, pathname]);
return [prefetch, ref] as const;
}
完成
最後に独自 Link コンポーネントで useApiPrefetch
を使用する分岐を加えれば完成です。main ブランチと比較すると、キャッシュが保持される前の...loading
表示のチラつきが減っていることが確認できます。
function Prefetch<
PagePath extends keyof Pages,
ApiPath extends keyof GetResBody,
ReqQuery extends GetReqQuery[ApiPath],
ReqBody extends GetReqBody[ApiPath]
>({
children,
...options
}: Prefetch<ApiPath, ReqQuery, ReqBody, PagePath> & {
children: React.ReactNode;
}) {
const [prefetch, setIntersectionRef] = useApiPrefetch(options);
return (
<span onMouseEnter={prefetch} ref={setIntersectionRef}>
{children}
</span>
);
}
export function Link<
PagePath extends keyof Pages,
PageQuery extends Pages[PagePath],
ApiPath extends keyof GetResBody,
ReqQuery extends GetReqQuery[ApiPath],
ReqBody extends GetReqBody[ApiPath]
>({
path,
query,
swrPrefetch,
children,
...props
}: LinkBase & {
path: PagePath;
query?: PageQuery;
swrPrefetch?: Prefetch<ApiPath, ReqQuery, ReqBody, PagePath>;
}) {
const href = mapPathParamFromQuery(path, query);
if (!swrPrefetch) {
return (
<NextLink {...props} href={href}>
<a>{children}</a>
</NextLink>
);
}
return (
<NextLink {...props} href={href}>
<a>
<Prefetch {...swrPrefetch}>{children}</Prefetch>
</a>
</NextLink>
);
}
SWR を使ったサンプルでしたが、出力された型定義を使えば、React Query でも同じ様に実施できるはずです。Routing / Prefetching / Caching / TypeSafe は、今後も不可分な関係になるのではないかと考えています。
Discussion