本文へスキップ
URL に state を置く

Part 9 · state を共有する

51 / 64

URL に state を置く

リロードしても消えない状態

状態を置く場所は、useState だけではありません。

検索条件や、選んでいるタブ。 こういうものを useState に入れると、リロードで消え、URL を送っても相手には伝わりません

置き場所をURL にすると、それが全部解決します。

書き方は useState とほぼ同じ

// いつもの
const [keyword, setKeyword] = useState("");

// 置き場所を URL にしたもの
const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

形はまったく同じです。 受け取るものも、更新のしかたも変わりません。 変わったのは、値がどこに保管されているかだけです。

URL に置いた検索条件

打つたびにアドレス欄が変わる

読み込み中…

入力して、次の 3 つを試してみてください。

  • アドレス欄を見る ?keyword=… が付いています
  • リロードする … 入力が残っています
  • URL をコピーして別のタブで開く 同じ絞り込みの状態で開きます

url-state.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";

const ALL = ["りんご", "みかん", "ぶどう", "もも", "いちご"];

export function UrlState() {
  useTrackDemoRender();

  // useState とほとんど同じ形。置き場所が URL になっただけ
  const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

  const found = ALL.filter((item) => item.includes(keyword));

  return (
    <div className="flex flex-col gap-4">
      <Input
        placeholder="果物を絞り込む"
        aria-label="果物を絞り込む"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />

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

      <Button size="sm" variant="outline" onClick={() => setKeyword("")}>
        クリア
      </Button>

      <p className="text-muted-foreground">
        打つたびに、ブラウザのアドレス欄が変わります
      </p>
    </div>
  );
}

URL に置くと何が起きるか

URL はブラウザが元から持っている状態の置き場所です。 そこに乗せるだけで、ブラウザの機能がそのまま使えるようになります。

  • リロードしても消えない
  • URL を送れば、同じ画面を相手に見せられる
  • 戻る / 進むボタンを効かせることもできる(後述)
  • お気に入りに登録できる

とくに 2 つめが大きい。「この検索結果を見てほしい」というとき、URL を貼るだけで済みますuseState ではこうはいきません。

url-state.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";

const ALL = ["りんご", "みかん", "ぶどう", "もも", "いちご"];

export function UrlState() {
  useTrackDemoRender();

  // useState とほとんど同じ形。置き場所が URL になっただけ
  const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

  const found = ALL.filter((item) => item.includes(keyword));

  return (
    <div className="flex flex-col gap-4">
      <Input
        placeholder="果物を絞り込む"
        aria-label="果物を絞り込む"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />

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

      <Button size="sm" variant="outline" onClick={() => setKeyword("")}>
        クリア
      </Button>

      <p className="text-muted-foreground">
        打つたびに、ブラウザのアドレス欄が変わります
      </p>
    </div>
  );
}

素で書くと面倒なので、道具を使う

URL の読み書きは、自分でやると意外と手間がかかります。

  • 文字列なので、数値や真偽値は自分で変換する
  • 値がないときの既定値を毎回書く
  • 更新するときに他のパラメータを壊さないよう気をつける

デモで使っている nuqs は、これをuseState と同じ形に包んでくれるライブラリです。 型変換も面倒を見てくれます。

// 数値として扱う
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));

// 真偽値として扱う
const [open, setOpen] = useQueryState("open", parseAsBoolean.withDefault(false));

Suspense で包む必要がある(Next.js 固有の話)

Next.js でこれを使うと、ビルド時にエラーが出ることがあります。

Error occurred prerendering page "/lessons/url-state"

Next.js は、ページをあらかじめ組み立てておくことがあります。 ですがその時点では URL がまだ決まっていません。 クエリ文字列は、実際にアクセスされて初めて分かるものだからです。

そこで、URL を読む部品を Suspense で包み、「ここは後から埋まる」と React に伝えます

<Suspense fallback={<p>読み込み中…</p>}>
  <SearchBox />
</Suspense>

このデモも、そうやって包んであります。useSearchParams を直接使う場合も同じ対処が必要です。

url-state.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";

const ALL = ["りんご", "みかん", "ぶどう", "もも", "いちご"];

export function UrlState() {
  useTrackDemoRender();

  // useState とほとんど同じ形。置き場所が URL になっただけ
  const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

  const found = ALL.filter((item) => item.includes(keyword));

  return (
    <div className="flex flex-col gap-4">
      <Input
        placeholder="果物を絞り込む"
        aria-label="果物を絞り込む"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />

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

      <Button size="sm" variant="outline" onClick={() => setKeyword("")}>
        クリア
      </Button>

      <p className="text-muted-foreground">
        打つたびに、ブラウザのアドレス欄が変わります
      </p>
    </div>
  );
}

理解できたか確かめる

確認クイズ

検索キーワードを URL に置く利点は?

確認クイズ

次のうち、URL に置くべきなのはどれ?

確認クイズ

検索窓の文字を URL に入れるとき、履歴はどう扱う?

url-state.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";

const ALL = ["りんご", "みかん", "ぶどう", "もも", "いちご"];

export function UrlState() {
  useTrackDemoRender();

  // useState とほとんど同じ形。置き場所が URL になっただけ
  const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

  const found = ALL.filter((item) => item.includes(keyword));

  return (
    <div className="flex flex-col gap-4">
      <Input
        placeholder="果物を絞り込む"
        aria-label="果物を絞り込む"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />

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

      <Button size="sm" variant="outline" onClick={() => setKeyword("")}>
        クリア
      </Button>

      <p className="text-muted-foreground">
        打つたびに、ブラウザのアドレス欄が変わります
      </p>
    </div>
  );
}

この章のまとめ

  • 状態の置き場所は useState だけではない
  • URL に置くと、リロードで消えず、URL を送れば共有できる
  • 判断の目安は「他人に見せて意味があるか」
  • nuqs のような道具を使えば、書き方は useState と同じ形になる

url-state.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";

const ALL = ["りんご", "みかん", "ぶどう", "もも", "いちご"];

export function UrlState() {
  useTrackDemoRender();

  // useState とほとんど同じ形。置き場所が URL になっただけ
  const [keyword, setKeyword] = useQueryState("keyword", { defaultValue: "" });

  const found = ALL.filter((item) => item.includes(keyword));

  return (
    <div className="flex flex-col gap-4">
      <Input
        placeholder="果物を絞り込む"
        aria-label="果物を絞り込む"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />

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

      <Button size="sm" variant="outline" onClick={() => setKeyword("")}>
        クリア
      </Button>

      <p className="text-muted-foreground">
        打つたびに、ブラウザのアドレス欄が変わります
      </p>
    </div>
  );
}