Open6
React Router | SSR・CSR・SSGサンプル
事前準備
サンプルのAPIは、pokeapiを利用する。
型定義を準備
app/routes/__.poc/types/poke.ts
export interface PokeResourceList {
count: number;
next: string;
previous: string;
results: PokeResource[];
}
export interface PokeResource {
name: string;
url: string;
}
CSR
1. CSRページを作成
app/routes/__.poc.sample-csr._index/route.tsx
import { Link } from 'react-router';
import { Button } from '~/components/shadcn/ui/button';
import type { PokeResourceList } from '../__.poc/types/poke';
import type { Route } from './+types/route';
// clientLoaderは、クライアントサイドでのみ実行される
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
console.log('clientloader for CSR!');
// NOTE: URLSearchParamsを使ってクエリパラメータを取得
const url = new URL(request.url);
const offset = url.searchParams.get('offset') || '0';
const limit = url.searchParams.get('limit') || '20';
const res = await fetch(
`https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`,
);
const data = (await res.json()) as PokeResourceList;
return { data, offset, limit };
};
// NOTE: HydrateFallback は、クライアントローダーが実行中にレンダリングされる
export const HydrateFallback = () => {
return <div>Loading...</div>;
};
const PocSampleCsrPage = ({ loaderData }: Route.ComponentProps) => {
const { data, offset, limit } = loaderData;
const nextOffset = Number.parseInt(offset) + Number.parseInt(limit);
const prevOffset = Number.parseInt(offset) - Number.parseInt(limit);
return (
<div className="container mx-auto flex flex-col gap-4 p-4">
<h1 className="font-bold text-xl">Sample CSR Page</h1>
<h2 className="text-lg">Pokemon List</h2>
<ul>
{data.results.map((pokemon) => (
<li key={pokemon.name}>
<div className="grid grid-cols-2 gap-4">
<div>{pokemon.name}</div>
<div>{pokemon.url}</div>
</div>
</li>
))}
</ul>
<div className="flex gap-4">
{/* NOTE: Linkにdisabledプロパティがないため、pointer-events-noneで代用 */}
<div className={data.previous ? '' : 'pointer-events-none'}>
<Link to={`?offset=${prevOffset}&limit=${limit}`}>
<Button className="w-24" disabled={!data.previous}>
Previous
</Button>
</Link>
</div>
<div className={data.next ? '' : 'pointer-events-none'}>
<Link to={`?offset=${nextOffset}&limit=${limit}`}>
<Button className="w-24" disabled={!data.next}>
Next
</Button>
</Link>
</div>
</div>
</div>
);
};
export default PocSampleCsrPage;
UIの一部にローディング表示させる場合
loaderDataではなく、useFetcher経由でデータを取得し、fether.stateを利用する。
import { useEffect } from 'react';
import { useFetcher, useParams } from 'react-router';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '~/components/shadcn/ui/card';
import { LoadingDots } from '~/components/shared/loading-dots/loading-dots';
import type { Pokemon } from '../__.poc/types/pokemon';
import type { Route } from './+types/route';
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const { pokemonId } = params;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonId}`);
const pokemon = (await res.json()) as Pokemon;
return { pokemon };
};
export const HydrateFallback = () => {
return <LoadingDots />;
};
const PokemonPage = ({ loaderData }: Route.ComponentProps) => {
const fetcher = useFetcher<typeof loaderData>();
const pokemon = fetcher.data?.pokemon;
const { pokemonId } = useParams();
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
fetcher.load(`/poc/remix-tutorial/pokemons/${pokemonId}`);
}, [pokemonId]);
return (
<div className="container mx-auto p-4">
{fetcher.state !== 'idle' ? <LoadingDots /> : null}
<Card>
<CardHeader>
<CardTitle className="font-bold text-2xl">
<h2>{pokemon?.name}</h2>
</CardTitle>
</CardHeader>
<CardContent>
<img src={pokemon?.sprites.front_default ?? ''} alt={pokemon?.name} />
<CardDescription className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h3 className="mt-4 font-bold text-xl">Base</h3>
<ul>
<li>Height: {pokemon?.height}</li>
<li>Weight: {pokemon?.weight}</li>
</ul>
</div>
<div>
<h3 className="mt-4 font-bold text-xl">Status</h3>
<ul>
{pokemon?.abilities.map((ability) => (
<li key={ability.ability.name}>{ability.ability.name}</li>
))}
</ul>
</div>
<div>
<h3 className="mt-4 font-bold text-xl">Types</h3>
<ul>
{pokemon?.types.map((type) => (
<li key={type.type.name}>{type.type.name}</li>
))}
</ul>
</div>
<div>
<h3 className="mt-4 font-bold text-2xl">Stats</h3>
<ul>
{pokemon?.stats.map((stat) => (
<li key={stat.stat.name}>
{stat.stat.name}: {stat.base_stat}
</li>
))}
</ul>
</div>
</CardDescription>
</CardContent>
</Card>
</div>
);
};
export default PokemonPage;
SSR
1. SSRページを作成
app/routes/__.poc.sample-ssr._index/route.tsx
import { Link } from 'react-router';
import { Button } from '~/components/shadcn/ui/button';
import type { PokeResourceList } from '../__.poc/types/poke';
import type { Route } from './+types/route';
// loaderは、サーバーサイドでのみ実行される
export const loader = async ({ request }: Route.LoaderArgs) => {
console.log('loader for SSR!');
// NOTE: URLSearchParamsを使ってクエリパラメータを取得
const url = new URL(request.url);
const offset = url.searchParams.get('offset') || '0';
const limit = url.searchParams.get('limit') || '20';
const res = await fetch(
`https://pokeapi.co/api/v2/pokemon?offset=${offset}&limit=${limit}`,
);
const data = (await res.json()) as PokeResourceList;
return { data, offset, limit };
};
const PocSampleSsrPage = ({ loaderData }: Route.ComponentProps) => {
const { data, offset, limit } = loaderData;
const nextOffset = Number.parseInt(offset) + Number.parseInt(limit);
const prevOffset = Number.parseInt(offset) - Number.parseInt(limit);
return (
<div className="container mx-auto flex flex-col gap-4 p-4">
<h1 className="font-bold text-xl">Sample SSR Page</h1>
<h2 className="text-lg">Pokemon List</h2>
<ul>
{data.results.map((pokemon) => (
<li key={pokemon.name}>
<div className="grid grid-cols-2 gap-4">
<div>{pokemon.name}</div>
<div>{pokemon.url}</div>
</div>
</li>
))}
</ul>
<div className="flex gap-4">
{/* NOTE: Linkにdisabledプロパティがないため、pointer-events-noneで代用 */}
<div className={data.previous ? '' : 'pointer-events-none'}>
<Link to={`?offset=${prevOffset}&limit=${limit}`}>
<Button className="w-24" disabled={!data.previous}>
Previous
</Button>
</Link>
</div>
<div className={data.next ? '' : 'pointer-events-none'}>
<Link to={`?offset=${nextOffset}&limit=${limit}`}>
<Button className="w-24" disabled={!data.next}>
Next
</Button>
</Link>
</div>
</div>
</div>
);
};
export default PocSampleSsrPage;
SSG
1. プリレンダリングするページを指定
react-router.config.ts
import type { Config } from '@react-router/dev/config';
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
+ async prerender() {
+ return ['/poc/sample-ssg'];
+ },
} satisfies Config;
2. SSGページを作成
app/routes/__.poc.sample-ssg._index/route.tsx
const PocSampleSsgPage = () => {
return (
<>
<h1 className="font-bold text-xl">Sample SSG Page</h1>
<div>
<h2>About Me</h2>
<p>
Hello! My name is Atman. I am a software developer with a passion for
creating web applications. I have experience in various technologies
including React, Node.js, and TypeScript. In my free time, I enjoy
learning new programming languages.
</p>
</div>
</>
);
};
export default PocSampleSsgPage;
SSR+CSR(clientAction)
1. SSR+CSRページを作成
app/routes/__.poc.sample-ssr-csr._index/route.tsx
import { useEffect, useRef } from 'react';
import { useFetcher } from 'react-router';
import { Button } from '~/components/shadcn/ui/button';
import { Input } from '~/components/shadcn/ui/input';
import type { PokeResource } from '../__.poc/types/poke';
import type { Route } from './+types/route';
const fetchPokes = async (keyword?: string) => {
// PokeAPI から全ポケモンリストを取得
const response = await fetch('https://pokeapi.co/api/v2/pokemon?limit=1000');
const data = await response.json();
const allPokemon: PokeResource[] = data.results;
if (!keyword) {
return { pokemons: allPokemon };
}
// keywordが指定されている場合は、一致するポケモンをフィルタリング
console.log('Filtering by keyword:', keyword);
const filtered = allPokemon.filter((p) => p.name.includes(keyword));
return { pokemons: filtered };
};
export const loader = async () => {
console.log('loader for SSR!');
const { pokemons } = await fetchPokes();
return { pokemons, type: 'ssr' };
};
export const clientAction = async ({ request }: Route.ClientActionArgs) => {
console.log('clientAction for CSR!');
const formData = await request.formData();
// NOTE: Object.fromEntriesで、type submitのvalueを取得する
const { _action } = Object.fromEntries(formData);
switch (_action) {
case 'search': {
const keyword = formData.get('keyword') as string;
const { pokemons } = await fetchPokes(keyword);
return { pokemons, type: 'csr', action: 'search' };
}
case 'reset': {
const { pokemons } = await fetchPokes();
return { pokemons, type: 'csr', action: 'reset' };
}
}
};
const PocSampleSsrCsrPage = ({
loaderData,
actionData,
}: Route.ComponentProps) => {
const formRef = useRef<HTMLFormElement>(null);
// NOTE: actionDataだと、loading中か判断できないため、useFetcherを使う
const fetcher = useFetcher<typeof actionData>();
const pokemons = fetcher.data?.pokemons || loaderData.pokemons;
const type = fetcher.data?.type || loaderData.type;
console.log('type:', type);
useEffect(() => {
if (fetcher.data?.action === 'reset') {
formRef.current?.reset();
}
}, [fetcher]);
return (
<div className="container mx-auto flex flex-col gap-4 p-4">
<h1 className="font-bold text-xl">Sample SSR CSR Page</h1>
<h2 className="text-lg">Pokemon List</h2>
<fetcher.Form
action="/poc/sample-ssr-csr"
method="post"
className="flex flex-col gap-4"
ref={formRef}
>
<Input type="text" name="keyword" placeholder="Search Pokemon" />
<div className="flex gap-4">
<Button type="submit" name="_action" value="search">
Search
</Button>
<Button type="submit" name="_action" value="reset">
Reset
</Button>
</div>
</fetcher.Form>
{fetcher.state !== 'idle' ? (
<div>Loading...</div>
) : (
<ul>
{pokemons.map((pokemon) => (
<li key={pokemon.name}>
<div className="grid grid-cols-2 gap-4">
<div>{pokemon.name}</div>
<div>{pokemon.url}</div>
</div>
</li>
))}
</ul>
)}
</div>
);
};
export default PocSampleSsrCsrPage;