Open6

React Router | SSR・CSR・SSGサンプル

atmanatman

事前準備

サンプルの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;
}
atmanatman

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;
atmanatman

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;
atmanatman

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;
atmanatman

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;
atmanatman

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;