本文へスキップ
サーバーのデータ

Part 9 · state を共有する

53 / 64

サーバーのデータ

取得したデータは state とは別物として扱う

最後の置き場所はサーバーです。 そしてこれだけは、これまでと性質が違います。

これまでの 3 つは、自分が持ち主でした。 サーバーのデータは違います。持ち主は向こうにいて、こちらが持っているのは写しです。

その違いが、扱い方を変えます。

自分で書くと、state が増えていく

Part 6 でやった useEffect を使えば、 データの取得そのものは書けます。

useEffect で取ってくるうまくいかない例

動くが、state が 3 つ必要になっている

読み込み中…

Part 8 の Suspense で一度出した形です。 動きます。ですが、state が 3 つ必要になりました。

const [members, setMembers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

しかもまだ足りません。 実際のアプリでは、こういう要求が次々に来ます。

  • 「取り直す」ボタンを付けたい
  • 同じデータを別の画面でも使いたい(また取りに行く?)
  • 連続で押されたとき、古い結果が後から届いて上書きするのを防ぎたい
  • タブに戻ってきたら最新にしたい
  • 失敗したら数回やり直したい

これを全部、正しく書くのは大変です。そしてアプリのどこでデータを取るときも、同じ苦労を繰り返します。

manual-fetch.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";

/*
  自分で書くとこうなる、という例。
  lint はこの書き方を「描き直しが連鎖する」として止めてくる。
  それも含めて「自前で正しく書くのは大変だ」という話なので、
  このファイルだけ黙らせている。
*/
/* eslint-disable react-hooks/set-state-in-effect */

export function ManualFetch() {
  useTrackDemoRender();

  // 取得したデータのために、state が 3 つも必要になる
  const [members, setMembers] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);

    fetch("/api/members")
      .then((res) => res.json())
      .then((data) => setMembers(data.members))
      .catch(() => setError("取得に失敗しました"))
      .finally(() => setIsLoading(false));
  }, []);

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">{error}</p>}

      {!isLoading && !error && (
        <ul className="flex flex-wrap gap-2">
          {members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" disabled>
        取り直す(自分で書くと、ここも作り込みが要る)
      </Button>
    </div>
  );
}

取得は道具に任せる

そこで、データ取得専用の道具を使います。デモでは SWR を使っています。

const { data, error, isLoading, mutate } = useSWR("/api/members", fetcher);

第 2 引数の fetcher は、取ってくる処理そのものです。自分で書きます。

const fetcher = (url) => fetch(url).then((res) => res.json());

「道具を入れたのに、なぜ自分で書くのか」と思うかもしれません。 SWR が引き受けるのはいつ・何回・どう取り直すかのほうで、取ってくる手段は使う側が決めるという作りだからです。 だから GraphQL でも、別のライブラリでも、そのまま乗せられます。

この 1 行に、さっきの state 3 つが全部入っています。さらに mutate(取り直す)まで付いてきます。

SWR に任せる直した例

取り直すボタンが動く。押すたびに違う人が返る

読み込み中…

さきほど挙げた要求も、ほとんどが最初から入っています。 タブに戻ったときの再取得も、同じ URL の重複防止も、自分では書きません

with-swr.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import useSWR from "swr";

// 404 や 500 でも fetch は成功扱いになる。
// ここで throw しておかないと、失敗が error に入ってこない
const fetcher = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error("取得に失敗しました");
  return response.json();
};

export function WithSwr() {
  useTrackDemoRender();

  // 読み込み中も、失敗も、取り直しも、これ 1 行に含まれている
  const { data, error, isLoading, mutate } = useSWR<{ members: string[] }>(
    "/api/members",
    fetcher,
  );

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">取得に失敗しました</p>}

      {data && (
        <ul className="flex flex-wrap gap-2">
          {data.members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" onClick={() => mutate()}>
        取り直す
      </Button>
    </div>
  );
}

state として持たない

ここが、この章でいちばん大事なところです。

// ✕ 取ってきたデータを state に写す
const { data } = useSWR("/api/members", fetcher);
const [members, setMembers] = useState([]);

useEffect(() => {
  if (data) setMembers(data.members);
}, [data]);

Part 6 でやった「props を state に写す」とまったく同じ間違いです。 写した瞬間に二重管理が始まります。

サーバーのデータは、こちらが持ち主ではありません。いつでも取り直せる「写し」として扱います。 自分の state にしてしまうと、 向こうが変わったことに気づけなくなります。

with-swr.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import useSWR from "swr";

// 404 や 500 でも fetch は成功扱いになる。
// ここで throw しておかないと、失敗が error に入ってこない
const fetcher = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error("取得に失敗しました");
  return response.json();
};

export function WithSwr() {
  useTrackDemoRender();

  // 読み込み中も、失敗も、取り直しも、これ 1 行に含まれている
  const { data, error, isLoading, mutate } = useSWR<{ members: string[] }>(
    "/api/members",
    fetcher,
  );

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">取得に失敗しました</p>}

      {data && (
        <ul className="flex flex-wrap gap-2">
          {data.members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" onClick={() => mutate()}>
        取り直す
      </Button>
    </div>
  );
}

ほかの選択肢

  • TanStack Query SWR と同じ役割で、より多機能。大きなアプリで選ばれることが多い
  • Server Component Next.js なら、そもそもサーバー側でデータを取って 完成した画面を送れます。取得の状態管理自体がなくなります
// Server Component なら、これだけで済む
async function MemberList() {
  const members = await getMembers();
  return <ul>{members.map(...)}</ul>;
}

読み込み中も、失敗も、二重管理も出てきません。まずこれで済まないかを考えるのが、いまの標準的な進め方です。

SWR や TanStack Query が要るのは、ブラウザ側で取り直したいときです。 検索、無限スクロール、定期更新など、操作に応じて何度も取りに行く場面になります。

with-swr.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import useSWR from "swr";

// 404 や 500 でも fetch は成功扱いになる。
// ここで throw しておかないと、失敗が error に入ってこない
const fetcher = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error("取得に失敗しました");
  return response.json();
};

export function WithSwr() {
  useTrackDemoRender();

  // 読み込み中も、失敗も、取り直しも、これ 1 行に含まれている
  const { data, error, isLoading, mutate } = useSWR<{ members: string[] }>(
    "/api/members",
    fetcher,
  );

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">取得に失敗しました</p>}

      {data && (
        <ul className="flex flex-wrap gap-2">
          {data.members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" onClick={() => mutate()}>
        取り直す
      </Button>
    </div>
  );
}

理解できたか確かめる

確認クイズ

サーバーから取ってきたデータを useState に入れるべき?

確認クイズ

useSWR のキーが同じだと何が起きる?

with-swr.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import useSWR from "swr";

// 404 や 500 でも fetch は成功扱いになる。
// ここで throw しておかないと、失敗が error に入ってこない
const fetcher = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error("取得に失敗しました");
  return response.json();
};

export function WithSwr() {
  useTrackDemoRender();

  // 読み込み中も、失敗も、取り直しも、これ 1 行に含まれている
  const { data, error, isLoading, mutate } = useSWR<{ members: string[] }>(
    "/api/members",
    fetcher,
  );

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">取得に失敗しました</p>}

      {data && (
        <ul className="flex flex-wrap gap-2">
          {data.members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" onClick={() => mutate()}>
        取り直す
      </Button>
    </div>
  );
}

この章のまとめ

  • サーバーのデータは持ち主が向こうにある「写し」
  • 自分で書くと、読み込み中・失敗・取り直し・重複防止を 全部作り込むことになる
  • SWR などの道具に任せると、その全部が 1 行に収まる
  • 取ってきたデータを state に写さない。二重管理になる
  • Next.js なら、まずServer Component で済まないかを考える

with-swr.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import useSWR from "swr";

// 404 や 500 でも fetch は成功扱いになる。
// ここで throw しておかないと、失敗が error に入ってこない
const fetcher = async (url: string) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error("取得に失敗しました");
  return response.json();
};

export function WithSwr() {
  useTrackDemoRender();

  // 読み込み中も、失敗も、取り直しも、これ 1 行に含まれている
  const { data, error, isLoading, mutate } = useSWR<{ members: string[] }>(
    "/api/members",
    fetcher,
  );

  return (
    <div className="flex flex-col gap-3">
      {isLoading && <p className="text-muted-foreground">読み込み中…</p>}
      {error && <p className="text-destructive">取得に失敗しました</p>}

      {data && (
        <ul className="flex flex-wrap gap-2">
          {data.members.map((member) => (
            <li key={member} className="rounded-md border px-3 py-1.5">
              {member}
            </li>
          ))}
        </ul>
      )}

      <Button size="sm" variant="outline" onClick={() => mutate()}>
        取り直す
      </Button>
    </div>
  );
}