Remix v2.9 で導入された Single Fetch
Remix v2.9 で導入された Single Fetch はクライアントサイドでのページ遷移が行われた際に、サーバーへの複数の HTTP リクエストを並行して行う代わりに、1 つの HTTP リクエストを実行しまとめてレスポンスを返す機能です。Single Fetch は v2.9 ではフィーチャーフラグとして提供されており、v3 以降ではデフォルトの挙動となります。
Remix に対してドキュメントリクエストが行われると、Remix はリクエストパスにマッチしたすべての loader 関数を呼び出し、それらの結果を組み合わせてページを構築します。対して、ユーザーがクライアントサイドでのページ遷移を行った場合、Remix はそれぞれの loader
関数ごとに個別のリクエストをサーバーに対して行います。
このように、ドキュメントリクエストを行う場合とクライアントサイドでのページ遷移する場合で、Remix は一貫性のない方法でデータ取得を行っているという問題点がありました。
Remix v2.9 で導入された Single Fetch はクライアントサイドでのページ遷移が行われた際に、サーバーへの複数の HTTP リクエストを並行して行う代わりに、1 つの HTTP リクエストを実行しまとめてレスポンスを返す機能です。いくつかの API の破壊的変更はありますが、アプリケーションのコードに大きな変更を加えることなく、Single Fetch を導入するできます。Single Fetch は v2.9 ではフィーチャーフラグとして提供されており、v3 以降ではデフォルトの挙動となります。
Single Fetch には以下のような利点があげられています。
- CDN キャッシュカバレッジの向上
- よりシンプルなヘッダーの操作
- Remix 自体のコードの簡素化
また将来以下の機能を実装するための準備としての役割も担っています。
- Middleware
- Server Context
- 静的データの事前生成
- 効率的なサブリクエストのキャッシュ
- React Server Components のサポート
- より詳細な再検証
Single Fetch を導入する
それでは実際に Single Fetch の挙動を試してみましょう。サンプルコードとして、記事の一覧を取得する画面を考えます。この画面はネストされたルートで構成されており、/blog/1
に遷移すると、/blog
と /blog/1
の両方のパスにマッチします。
// /blog
import { Link, Outlet, json, useLoaderData } from "@remix-run/react";
import { getPosts } from "~/data";
export async function loader() {
const posts = await getPosts();
return json({
posts,
});
}
export default function Blog() {
const { posts } = useLoaderData<typeof loader>();
return (
<>
<h1>Blog</h1>
<Link to="/blog/new">Create Blog Post</Link>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/blog/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
<Outlet />
</>
);
}
// /blog/1
import type { LoaderFunctionArgs } from "@remix-run/node";
import { defer, json } from "@remix-run/node";
import { Await, Outlet, useLoaderData } from "@remix-run/react";
import { getComments, getPost } from "~/data";
import Comments from "./Comments";
import { Suspense } from "react";
export const loader = async ({ params }: LoaderFunctionArgs) => {
if (params.id === undefined) {
throw new Error("No ID provided");
}
const comments = getComments(Number(params.id));
const post = await getPost(Number(params.id));
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return defer({ post, comments });
};
export default function BlogPost() {
const { post, comments } = useLoaderData<typeof loader>();
return (
<div>
<h1>{post.title}</h1>
<time>{post.createdAt}</time>
<p>{post.body}</p>
<Suspense fallback={<p>Loading comments...</p>}>
<Await resolve={comments}>
{(resolveComments) => <Comments comments={resolveComments} />}
</Await>
</Suspense>
</div>
);
}
トップページから /blog/1
への遷移を行った場合、/blog
と /blog/1
のそれぞれマッチしたパスの loader
関数が呼び出されます。Devtools のネットワークタブを確認すると、確かに 2 つのリクエストが行われていることが確認できます。
次に、Single Fetch フィーチャーフラグを有効にして挙動を確認してみましょう。vite.config.js
で future.unstable_singleFetch
を有効にします。
import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// nativeFetch: true が必要
// https://github.com/remix-run/remix/issues/9324
installGlobals({
nativeFetch: true,
});
export default defineConfig({
plugins: [
remix({
future: {
unstable_singleFetch: true,
},
}),
tsconfigPaths(),
],
});
再度トップページから /blog/1
への遷移を行い、Devtools のネットワークタブを確認すると、1 つのリクエストのみが行われていることが確認できます。
破壊的な変更
Single Fetch にはいくつかの破壊的な変更があります。
- 新しいストリーミング形式による、データのシリアライズ形式の変更
action
関数の4xx
/5xx
エラーの際の再検証がオプトインとなるheaders
関数が使用されなくなる
新しいストリーミング形式
Remix では loader
/action
関数でデータをクライアントと受け渡しする際に、JSON.stringify
によりシリアライズを、defer 関数で Promise を返す際にはカスタムのストリーミング形式を使用していました。Single Fetch では turbo-stream を内部で使用するようになり、JSON よりも複雑なデータ構造をシリアライズ、デシリアライズが可能になります。
turbo-stream
は以下のデータ型を新たにサポートします。
BigInt
Date
Error
Map
Set
URL
Promise
RegExp
Symbol
loader
/action
関数において上記のデータ型を使用していた場合にコードの変更が必要となるかどうかは、どのような方法で値を返しているかにより変わります。json
関数を使用している場合には、引き続き JSON.stringify
によるシリアライズが行われます。そのため、コードを変更する必要はありません。
下記の例では Date
型が自動で string
型に変換されています。
type Post = {
id: number;
title: string;
body: string;
createdAt: Date;
};
export async function loader() {
const posts = await getPosts();
return json({
posts,
});
}
export default function Blog() {
const { posts } = useLoaderData<typeof loader>();
console.log(typeof posts[0].createdAt); // string
// ...
}
defer
関数またはオブジェクトをそのまま返していた場合には、turbo-stream
によるストリーミング形式が使用されるようになります。Date
型は string
型に変換されず、そのまま Date
型として受け取るように変更されます。
type Post = {
id: number;
title: string;
body: string;
createdAt: Date;
};
export async function loader(params) {
const posts = getPost(params.id);
return posts;
}
export default function Blog() {
const data = useLoaderData<typeof loader>();
console.log(typeof data.createdAt); // Date
// ...
}
このことは loader
関数から Promise を返すために、もはや defer
関数を使用する必要がないことを意味します。defer
関数を使用している箇所は単純なオブジェクトを返すように変更できます。
export async function loader(params) {
const comments = getComments(params.id);
const post = await getPost(params.id);
if (!post) {
throw new Response("Not Found", { status: 404 });
}
return { post, comments };
}
同様に以前までのシリアライズ形式を維持する必要がないのであれば、json
関数を使用せずにオブジェクトをそのまま返すことが好ましいでしょう。関数の返却方法の違いにより、型変換の一貫性が損なわれることを避けることができます。
型定義の修正
また、Single Fetch において正しく型推論が行われるようにするためにいくつかの修正が必要です。まずは tsconfig.json
の includes
に "node_modules/@remix-run/react/future/single-fetch.d.ts"
を追加します。
{
"include": [
// ...
"node_modules/@remix-run/react/future/single-fetch.d.ts"
]
}
useLoaderData
, useActionData
, useRouteLoaderData
, useFetcher
関数を使用している場合にはコードの変更は不要です。
export default function Blog() {
const data = useLoaderData<typeof loader>();
data.createdAt; // Date
// ...
}
useMatch
関数では型をキャストする際に UIMatch
から UIMatch_SingleFetch
に変更する必要があります。
let matches = useMatches();
- let rootMatch = matches[0] as UIMatch<typeof loader>;
+ let rootMatch = matches[0] as UIMatch_SingleFetch<typeof loader>;
meta
関数では MetaArgs
から MetaArgs_SingleFetch
に変更する必要があります。
export function meta({
data,
matches,
- }: MetaArgs<typeof loader, { root: typeof rootLoader }>) {
+ }: MetaArgs_SingleFetch<typeof loader, { root: typeof rootLoader }>) {
// ...
}
action
関数の再検証
以前までの Remix では action 関数の結果に関わらず、すべてのアクティブな loader
を再検証していました。この動作をオプトアウトするためには shouldRevalidate 関数を使用していました。
export const loader = () => {
// 決して変わることがないようなデータを返す
return json({
publicAccessKey: process.env.PUBLIC_ACCESS_KEY,
});
};
// このルートは常に再検証を行わない
export const shouldRevalidate = () => false;
例として、記事の作成画面を見てみましょう。フォームが送信されると、サーバーサイドで実行される action
関数内で新しい記事を作成し、記事の一覧画面へリダイレクトします。
import { ActionFunctionArgs } from "@remix-run/node";
import { Form, redirect } from "@remix-run/react";
import { createBlogPost } from "~/data";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
if (Math.random() > 0.5) {
throw new Response("Server error", { status: 500 });
}
await createBlogPost({
title: formData.get("title")?.toString() || "",
body: formData.get("body")?.toString() || "",
});
return redirect(`/blog`);
}
export default function New() {
return (
<div>
<h1>Create Blog Post</h1>
<Form method="post">
<input type="text" name="title" />
<textarea name="body" />
<button type="submit">Create</button>
</Form>
</div>
);
}
新しい記事を作成した後、loader()
関数が実行され新しい記事が一覧に表示されていることが確認できます。
Single Fetch では action
関数がステータスコード 4xx/5xx
を設定して返した場合にデフォルトで再検証が行われなくなります。action
関数が 4xx/5xx
エラーを返す多くの場合では、データのミューテーションを行っていないので、データを再読込する必要がないと考えられるためこのような変更が行われました。
引き続き 4xx/5xx
を返す際に再検証を行いたい場合には、loader
関数を呼び出しているルートごとに shouldRevalidate
関数を export し、
返り値として true
を返すことで常に再検証が行われるようになります。
export const loader = () => {
const posts = getPosts();
return json(posts);
};
// action 関数が実行されると常に loader 関数を再検証する
export const shouldRevalidate = () => true;
shouldRevalidate
関数の引数の unstable_actionStatus
には直前の action
関数で返されたのステータスコードが渡されます。このプロパティを使用することで、特定のステータスコードの場合に再検証するかどうかを判断できます。
import type { ShouldRevalidateFunction } from "@remix-run/node";
export const shouldRevalidate: ShouldRevalidateFunction = ({
unstable_actionStatus,
}) => {
if (unstable_actionStatus === 200) {
return true;
}
return false;
};
headers
関数の廃止
headers 関数は、ルートごとに独自のレスポンスヘッダーを設定するために使用されていました。
import type { HeadersFunction } from "@remix-run/node";
export const headers: HeadersFunction = () => ({
"x-my-custom-header": "my-custom-value",
});
Single Fetch では headers
関数を export していても、その値はもはや使用されません。代わりに loader/action
関数の引数で受けとる response
オブジェクトを直接変更することでレスポンスヘッダーやステータスコードを設定できます。
export async function loader({ response }: LoaderFunctionArgs) {
response.status = 200;
response.headers.append("x-my-custom-header", "my-custom-value");
const posts = getPosts();
return posts;
}
loader/action
関数内で受け取る response
オブジェクトは各ルートの loader/action
ごとに異なるインスタンスとなります。ネストされたルートにおいて複数の loader
関数が呼び出される場合でも、別のルートの response
オブジェクトは参照できません。
ルートごとに異なる値が設定されている場合には、以下のルールに従って値が決定されます。
- ステータスコード
- すべてのステータスコードが設定されていない、または値が 300 未満の場合、最も深いルート(この例では
/blog/1
)のステータスコードが使用される - ステータスコードが 300 以上の場合、最も浅いルート(この例では
/blog
)のステータスコードが使用される
- すべてのステータスコードが設定されていない、または値が 300 未満の場合、最も深いルート(この例では
- ヘッダー
- すべてのヘッダー操作が完了した後に、ヘッダーの操作が再現され新しいヘッダーが作成される。この順番は
action
関数 →loader
関数の順で上から下へと適用される header.set
では子ハンドラが親ハンドラの値を上書きするheader.append
では親ハンドラと子ハンドラの両方から同じ値を設定するために使われるheader.delete
では親ハンドラの値を子ハンドラから削除するために使用される。子ハンドラが親ハンドラの値を削除することはできない
- すべてのヘッダー操作が完了した後に、ヘッダーの操作が再現され新しいヘッダーが作成される。この順番は
Single Fetch ではステータスコードを設定するために、新たに Response
オブジェクトを生成して返す必要がなくなりました。例えば 404 ステータスコードを返す場合は以下のように Response
オブジェクトを生成して throw
していました。
export async function loader({ params }: LoaderFunctionArgs) {
if (!post) {
throw new Response("Not Found", { status: 404 });
}
}
これをは以下のように変更できます。
export async function loader({ params, response }: LoaderFunctionArgs) {
if (!post) {
response.status = 404;
throw response;
}
}
同様に redirect()
関数によるリダイレクトも引数の response
オブジェクトを throw
する方法に変更できます。
export async function action({ request, response }: ActionFunctionArgs) {
response.status = 302;
response.headers.set("Location", "/blog");
throw response;
}
clientLoader
使用時の挙動の違い
clientLoader を使用している場合には Single Fetch の挙動が少々変わります。ルートファイルで clientLoader
を export している場合、Single Fetch がオプトアウトされそのルートのみ単独でデータ取得が実行され、その他の clientLoader
を export していないルートのみでリクエストがまとめられます。
例として /dashboards
と /dashboards/invoice
ではそれぞれ loader
のみが export
され、/dashboards/invoice/1
では clientLoader
が export
されている場合を考えます。
export async function loader() {
const dashboards = await getDashboards();
return { dashboards };
}
export async function loader() {
const invoices = await getInvoices();
return { invoices };
}
export async function loader({ params }: LoaderFunctionArgs) {
const invoice = await getInvoice(params.id);
return { invoice };
}
export async function clientLoader({ params, serverLoader }: ClientLoaderFunctionArgs) {
const serverData = await serverLoader();
const details = await getInvoiceDetails(params.id);
return {
...serverData,
...details,
};
}
ユーザーが /
から /dashboards/invoice/1
に遷移した場合、/dashboards
と /dashboards/invoice
の loader
関数が実行され、これらのリクエストはまとめられます。
GET /dashboards/invoice/1.data?_routes=routes/dashboards,routes/invoice
そして /dashboards/invoice/1
が呼び出されると、serverLoader
が実行され、独立したリクエストとしてデータ取得が行われます。
GET /dashboards/invoice/1.data?_routes=routes/dashboards/invoice/1
まとめ
- Remix v2.9 で導入された Single Fetch は、クライアントサイドでのページ遷移が行われた際に、サーバーへの複数のデータ取得を行う代わりに、1 つのデータ取得を行う機能
- Single Fetch にはいくつかの破壊的な変更があり、新しいストリーミング形式、
action
関数の再検証、headers
関数の廃止が含まれる loader
/action
関数がjson()
関数を使用して値を返している場合にはコードの変更は不要。defer
関数を使用している、またはオブジェクトをそのまま返している場合には、シリアライズ形式が変更されるためコードの変更が必要action
関数が4xx/5xx
エラーを返す場合には再検証がデフォルトで実行されなくなる。再検証を行いたい場合にはshouldRevalidate
関数でtrue
を返すheaders
関数はもはや使用されない。代わりにloader
/action
関数内で受け取るresponse
オブジェクトを直接変更することでレスポンスヘッダーを設定できる