本文へスキップ
買い物リストを作る

Part 11 · まとめて作る

60 / 64

買い物リストを作る

同じ画面の中で、値ごとに置き場所を選び分ける

TODO リストと同じ形のものを、もう 1 つ作ります。新しい道具はほとんど出てきません。

今回のテーマは 1 つだけです。どの値を、どこに置くか。Part 9 で置き場所を 1 つずつ見てきましたが、 実際の画面ではそれらが同時に出てきます

まず、扱うものを決める

TODO のときと同じ順番です。型から決めます。

type Category = "野菜" | "肉・魚" | "日用品" | "その他";

type Item = {
  id: number;
  name: string;
  category: Category;
  bought: boolean;
};

分類を string にせず 4 つに絞ったのは、 Part 0「TypeScript のさわり」でやったとおりです。 打ち間違いがその場で分かります。

絞り込みは、持たずに計算する

// 表示する分を、そのつど計算して出す
const shown = filterItems(items, keyword, category);

絞り込んだ結果を useState で持つと、元のリストと二重管理になります。 Part 4「state は最小限にする」でやった形です。 持つのは元のリストと、絞り込みの条件だけです。

types.ts

/** 買うものの分類。文字列を直接書かず、この 4 つに絞る */
export type Category = "野菜" | "肉・魚" | "日用品" | "その他";

export const categories: Category[] = ["野菜", "肉・魚", "日用品", "その他"];

/**
 * URL から来た文字列を Category に直す。
 *
 * URL は誰でも書き換えられるので、知らない値が入ってくる。
 * as で押し込むと型の上では通ってしまい、
 * 「絞り込んだのに 1 件も出ない」という形で静かに壊れる。
 */
export const toCategory = (value: string): Category | null =>
  categories.includes(value as Category) ? (value as Category) : null;

export type Item = {
  id: number;
  name: string;
  category: Category;
  bought: boolean;
};

export const initialItems: Item[] = [
  { id: 1, name: "にんじん", category: "野菜", bought: false },
  { id: 2, name: "とりむね肉", category: "肉・魚", bought: false },
  { id: 3, name: "洗剤", category: "日用品", bought: true },
  { id: 4, name: "電池", category: "その他", bought: false },
];

/**
 * 絞り込みは「持つ」ものではなく「計算する」もの。
 * ここを state にしないのが Part 4 の「state は最小限にする」。
 */
export const filterItems = (
  items: Item[],
  keyword: string,
  category: Category | null,
) =>
  items.filter((item) => {
    const matchesKeyword = item.name.includes(keyword);
    const matchesCategory = category === null || item.category === category;
    return matchesKeyword && matchesCategory;
  });

まず、全部 useState で書いてみる

素直に書けばこうなります。3 つとも useState です。

const [items, setItems] = useState(initialItems);
const [keyword, setKeyword] = useState("");
const [category, setCategory] = useState<Category | null>(null);
全部 useState に置いた版うまくいかない例

絞り込んで、チェックを付けて、そのあと再読み込みする

絞り込んでから再読み込みしてみてください

動きます。ちゃんと絞り込めますし、チェックも付きます。 問題はページを再読み込みしたときです。

  • 買うものが最初に戻ります。 さっき足したものも、チェックも消えます
  • 絞り込みも戻ります。 人に「野菜だけ見せたい」と思っても、URL を送れません
  • 戻るボタンが効きません。 絞り込みを間違えても、1 つ前には戻れません

どれも「バグ」ではありません。メモリに置いたのだから、そうなって当然です。 置き場所を間違えているだけです。

all-in-memory.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { categories, filterItems, initialItems, type Category } from "./types";

/** 全部 useState に置いた版。動くが、閉じると全部消える */
export function AllInMemory() {
  useTrackDemoRender();

  const [items, setItems] = useState(initialItems);
  const [keyword, setKeyword] = useState("");
  const [category, setCategory] = useState<Category | null>(null);

  const shown = filterItems(items, keyword, category);

  const toggle = (id: number) =>
    setItems((current) =>
      current.map((item) =>
        item.id === id ? { ...item, bought: !item.bought } : item,
      ),
    );

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

      <div className="flex flex-wrap gap-2">
        <Button
          size="sm"
          variant={category === null ? "default" : "outline"}
          onClick={() => setCategory(null)}
        >
          すべて
        </Button>
        {categories.map((name) => (
          <Button
            key={name}
            size="sm"
            variant={category === name ? "default" : "outline"}
            onClick={() => setCategory(name)}
          >
            {name}
          </Button>
        ))}
      </div>

      <ul className="flex flex-col gap-2">
        {shown.map((item) => (
          <li key={item.id}>
            <button
              type="button"
              onClick={() => toggle(item.id)}
              className="focus-ring flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left"
            >
              <span className={item.bought ? "line-through opacity-50" : ""}>
                {item.name}
              </span>
              <span className="ml-auto text-xs text-muted-foreground">
                {item.category}
              </span>
            </button>
          </li>
        ))}
      </ul>

      <p className="text-sm text-muted-foreground">
        絞り込んでから再読み込みしてみてください
      </p>
    </div>
  );
}

値ごとに、置き場所を決め直す

この画面には 3 種類の値があります。3 つとも性質が違います。

買い物リストの画面入力途中の品名useStateこの画面から離れたら消えてよい絞り込みの条件URL人に見せたい・戻るで戻りたい買うもの本体localStorage閉じても残ってほしい
置き場所は画面ごとではなく、値ごとに決まる

Part 9「状態の置き場所を選ぶ」で使った問いを、そのまま当てます。

  • 買うもの本体 閉じても残ってほしい。人に見せる必要はない → ブラウザに保存する
  • 絞り込みの条件 人に見せたい。戻るで戻りたい → URL に置く
  • 入力途中の品名 足したら消える。残っていたら邪魔 → useState
// 閉じても残す
const [items, setItems] = useLocalStorageState("...", {
  defaultValue: initialItems,
});

// 人に見せる・戻れるようにする
const [keyword, setKeyword] = useQueryState("q", { defaultValue: "" });

// この画面かぎり
const [draft, setDraft] = useState("");

3 つとも形が同じなのが分かると思います。 Part 9 で「また同じ形です」と繰り返し出てきたのは、 この日のためです。置き場所を変えるのに、書き方を覚え直す必要はありません。

shopping-list.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";
import { useState } from "react";
import useLocalStorageState from "use-local-storage-state";
import { FilterBar } from "./filter-bar";
import {
  filterItems,
  initialItems,
  toCategory,
  type Category,
  type Item,
} from "./types";

export function ShoppingList() {
  useTrackDemoRender();

  // 買うもの本体 … 閉じても残ってほしい → ブラウザに保存する
  const [items, setItems] = useLocalStorageState<Item[]>(
    "react-lesson-shopping-items",
    { defaultValue: initialItems },
  );

  // 絞り込み条件 … 人に見せたい・戻るで戻りたい → URL に置く
  const [keyword, setKeyword] = useQueryState("q", { defaultValue: "" });
  const [category, setCategory] = useQueryState<Category | null>("cat", {
    defaultValue: null,
    // 知らない値が URL に入っていたら、絞り込みなしとして扱う
    parse: toCategory,
    serialize: (value) => value ?? "",
  });

  // 入力途中の新しい品名 … この画面から離れたら消えてよい → useState
  const [draft, setDraft] = useState("");

  const shown = filterItems(items, keyword, category);

  const add = () => {
    const name = draft.trim();
    if (!name) return;

    setItems((current) => [
      ...current,
      {
        id: Math.max(0, ...current.map((item) => item.id)) + 1,
        name,
        category: category ?? "その他",
        bought: false,
      },
    ]);
    setDraft("");
  };

  const toggle = (id: number) =>
    setItems((current) =>
      current.map((item) =>
        item.id === id ? { ...item, bought: !item.bought } : item,
      ),
    );

  return (
    <div className="flex flex-col gap-4">
      <FilterBar
        keyword={keyword}
        onKeywordChange={setKeyword}
        category={category}
        onCategoryChange={setCategory}
      />

      <RenderBox title="買うもの(ブラウザに保存されている)" tone="highlight">
        <ul className="flex flex-col gap-2">
          {shown.map((item) => (
            <li key={item.id}>
              <button
                type="button"
                onClick={() => toggle(item.id)}
                className="focus-ring flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left"
              >
                <span className={item.bought ? "line-through opacity-50" : ""}>
                  {item.name}
                </span>
                <span className="ml-auto text-xs text-muted-foreground">
                  {item.category}
                </span>
              </button>
            </li>
          ))}
          {shown.length === 0 && (
            <li className="text-sm text-muted-foreground">
              条件に合うものがありません
            </li>
          )}
        </ul>
      </RenderBox>

      <div className="flex gap-2">
        <Input
          placeholder="買うものを足す"
          aria-label="買うものを足す"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
        <Button size="sm" onClick={add}>
          足す
        </Button>
      </div>

      <Button
        size="sm"
        variant="outline"
        onClick={() => setItems(initialItems)}
      >
        最初の状態に戻す
      </Button>
    </div>
  );
}

置き場所を直した版

値ごとに置き場所を決めた版直した例

絞り込んでから、再読み込み・戻る・URL のコピーを試す

読み込み中…

試すことは 4 つです。

  • 絞り込んでから再読み込み 買うものも絞り込みも、そのまま残ります
  • 戻るボタン 1 つ前の絞り込みに戻ります
  • アドレス欄をコピーして新しいタブで開く 同じ絞り込みの画面が出ます
  • 入力途中の品名だけは、 新しいタブで開くと空です。消えてほしい値だからです

shopping-list.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";
import { useState } from "react";
import useLocalStorageState from "use-local-storage-state";
import { FilterBar } from "./filter-bar";
import {
  filterItems,
  initialItems,
  toCategory,
  type Category,
  type Item,
} from "./types";

export function ShoppingList() {
  useTrackDemoRender();

  // 買うもの本体 … 閉じても残ってほしい → ブラウザに保存する
  const [items, setItems] = useLocalStorageState<Item[]>(
    "react-lesson-shopping-items",
    { defaultValue: initialItems },
  );

  // 絞り込み条件 … 人に見せたい・戻るで戻りたい → URL に置く
  const [keyword, setKeyword] = useQueryState("q", { defaultValue: "" });
  const [category, setCategory] = useQueryState<Category | null>("cat", {
    defaultValue: null,
    // 知らない値が URL に入っていたら、絞り込みなしとして扱う
    parse: toCategory,
    serialize: (value) => value ?? "",
  });

  // 入力途中の新しい品名 … この画面から離れたら消えてよい → useState
  const [draft, setDraft] = useState("");

  const shown = filterItems(items, keyword, category);

  const add = () => {
    const name = draft.trim();
    if (!name) return;

    setItems((current) => [
      ...current,
      {
        id: Math.max(0, ...current.map((item) => item.id)) + 1,
        name,
        category: category ?? "その他",
        bought: false,
      },
    ]);
    setDraft("");
  };

  const toggle = (id: number) =>
    setItems((current) =>
      current.map((item) =>
        item.id === id ? { ...item, bought: !item.bought } : item,
      ),
    );

  return (
    <div className="flex flex-col gap-4">
      <FilterBar
        keyword={keyword}
        onKeywordChange={setKeyword}
        category={category}
        onCategoryChange={setCategory}
      />

      <RenderBox title="買うもの(ブラウザに保存されている)" tone="highlight">
        <ul className="flex flex-col gap-2">
          {shown.map((item) => (
            <li key={item.id}>
              <button
                type="button"
                onClick={() => toggle(item.id)}
                className="focus-ring flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left"
              >
                <span className={item.bought ? "line-through opacity-50" : ""}>
                  {item.name}
                </span>
                <span className="ml-auto text-xs text-muted-foreground">
                  {item.category}
                </span>
              </button>
            </li>
          ))}
          {shown.length === 0 && (
            <li className="text-sm text-muted-foreground">
              条件に合うものがありません
            </li>
          )}
        </ul>
      </RenderBox>

      <div className="flex gap-2">
        <Input
          placeholder="買うものを足す"
          aria-label="買うものを足す"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
        <Button size="sm" onClick={add}>
          足す
        </Button>
      </div>

      <Button
        size="sm"
        variant="outline"
        onClick={() => setItems(initialItems)}
      >
        最初の状態に戻す
      </Button>
    </div>
  );
}

置き場所を知っているのは、1 か所だけ

絞り込みバーは、値が URL にあることを知りません。 受け取っているのは、ただの値とただの関数です。

function FilterBar({
  keyword,
  onKeywordChange,
  category,
  onCategoryChange,
}: { ... }) {

Part 2 でやった「値は下へ、知らせは上へ」です。 この形にしておくと、 あとで置き場所を URL からサーバーに変えたくなっても、直すのは親だけで済みます。

描き直しの範囲も見てください。 絞り込みバーに打つと、バーの箱もリストの箱も光ります。 条件が変われば、絞り込んだ結果も変わるので当然です。 ここは減らすところではありません。

filter-bar.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { categories, type Category } from "./types";

/**
 * 絞り込みの操作だけを持つ部品。
 * 値がどこに保管されているか(URL)は、この部品は知らない。
 * 受け取っているのは、ただの値と、ただの関数。
 */
export function FilterBar({
  keyword,
  onKeywordChange,
  category,
  onCategoryChange,
}: {
  keyword: string;
  onKeywordChange: (value: string) => void;
  category: Category | null;
  onCategoryChange: (value: Category | null) => void;
}) {
  return (
    <RenderBox title="絞り込みバー">
      <div className="flex flex-col gap-3">
        <Input
          placeholder="名前で絞り込む"
          aria-label="名前で絞り込む"
          value={keyword}
          onChange={(event) => onKeywordChange(event.target.value)}
        />

        <div className="flex flex-wrap gap-2">
          <Button
            size="sm"
            variant={category === null ? "default" : "outline"}
            onClick={() => onCategoryChange(null)}
          >
            すべて
          </Button>
          {categories.map((name) => (
            <Button
              key={name}
              size="sm"
              variant={category === name ? "default" : "outline"}
              onClick={() => onCategoryChange(name)}
            >
              {name}
            </Button>
          ))}
        </div>
      </div>
    </RenderBox>
  );
}

理解できたか確かめる

確認クイズ

「検索条件を人に送りたい」とき、条件はどこに置く?

確認クイズ

入力途中の品名を localStorage に置くと、何が困る?

確認クイズ

絞り込んだ結果を useState で持たないのはなぜ?

shopping-list.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";
import { useState } from "react";
import useLocalStorageState from "use-local-storage-state";
import { FilterBar } from "./filter-bar";
import {
  filterItems,
  initialItems,
  toCategory,
  type Category,
  type Item,
} from "./types";

export function ShoppingList() {
  useTrackDemoRender();

  // 買うもの本体 … 閉じても残ってほしい → ブラウザに保存する
  const [items, setItems] = useLocalStorageState<Item[]>(
    "react-lesson-shopping-items",
    { defaultValue: initialItems },
  );

  // 絞り込み条件 … 人に見せたい・戻るで戻りたい → URL に置く
  const [keyword, setKeyword] = useQueryState("q", { defaultValue: "" });
  const [category, setCategory] = useQueryState<Category | null>("cat", {
    defaultValue: null,
    // 知らない値が URL に入っていたら、絞り込みなしとして扱う
    parse: toCategory,
    serialize: (value) => value ?? "",
  });

  // 入力途中の新しい品名 … この画面から離れたら消えてよい → useState
  const [draft, setDraft] = useState("");

  const shown = filterItems(items, keyword, category);

  const add = () => {
    const name = draft.trim();
    if (!name) return;

    setItems((current) => [
      ...current,
      {
        id: Math.max(0, ...current.map((item) => item.id)) + 1,
        name,
        category: category ?? "その他",
        bought: false,
      },
    ]);
    setDraft("");
  };

  const toggle = (id: number) =>
    setItems((current) =>
      current.map((item) =>
        item.id === id ? { ...item, bought: !item.bought } : item,
      ),
    );

  return (
    <div className="flex flex-col gap-4">
      <FilterBar
        keyword={keyword}
        onKeywordChange={setKeyword}
        category={category}
        onCategoryChange={setCategory}
      />

      <RenderBox title="買うもの(ブラウザに保存されている)" tone="highlight">
        <ul className="flex flex-col gap-2">
          {shown.map((item) => (
            <li key={item.id}>
              <button
                type="button"
                onClick={() => toggle(item.id)}
                className="focus-ring flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left"
              >
                <span className={item.bought ? "line-through opacity-50" : ""}>
                  {item.name}
                </span>
                <span className="ml-auto text-xs text-muted-foreground">
                  {item.category}
                </span>
              </button>
            </li>
          ))}
          {shown.length === 0 && (
            <li className="text-sm text-muted-foreground">
              条件に合うものがありません
            </li>
          )}
        </ul>
      </RenderBox>

      <div className="flex gap-2">
        <Input
          placeholder="買うものを足す"
          aria-label="買うものを足す"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
        <Button size="sm" onClick={add}>
          足す
        </Button>
      </div>

      <Button
        size="sm"
        variant="outline"
        onClick={() => setItems(initialItems)}
      >
        最初の状態に戻す
      </Button>
    </div>
  );
}

この章のまとめ

  • 置き場所は画面ごとではなく、値ごとに決まる
  • 閉じても残すならブラウザの保存領域、人に見せる・戻れるなら URL、この画面かぎりなら useState
  • 3 つとも使い方の形は同じ。 だからあとから差し替えられる
  • 計算で出せるものは、どこにも置かない
  • 置き場所を知っているのは親だけにしておくと、変えるときに直す場所が 1 か所で済む

shopping-list.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useQueryState } from "nuqs";
import { useState } from "react";
import useLocalStorageState from "use-local-storage-state";
import { FilterBar } from "./filter-bar";
import {
  filterItems,
  initialItems,
  toCategory,
  type Category,
  type Item,
} from "./types";

export function ShoppingList() {
  useTrackDemoRender();

  // 買うもの本体 … 閉じても残ってほしい → ブラウザに保存する
  const [items, setItems] = useLocalStorageState<Item[]>(
    "react-lesson-shopping-items",
    { defaultValue: initialItems },
  );

  // 絞り込み条件 … 人に見せたい・戻るで戻りたい → URL に置く
  const [keyword, setKeyword] = useQueryState("q", { defaultValue: "" });
  const [category, setCategory] = useQueryState<Category | null>("cat", {
    defaultValue: null,
    // 知らない値が URL に入っていたら、絞り込みなしとして扱う
    parse: toCategory,
    serialize: (value) => value ?? "",
  });

  // 入力途中の新しい品名 … この画面から離れたら消えてよい → useState
  const [draft, setDraft] = useState("");

  const shown = filterItems(items, keyword, category);

  const add = () => {
    const name = draft.trim();
    if (!name) return;

    setItems((current) => [
      ...current,
      {
        id: Math.max(0, ...current.map((item) => item.id)) + 1,
        name,
        category: category ?? "その他",
        bought: false,
      },
    ]);
    setDraft("");
  };

  const toggle = (id: number) =>
    setItems((current) =>
      current.map((item) =>
        item.id === id ? { ...item, bought: !item.bought } : item,
      ),
    );

  return (
    <div className="flex flex-col gap-4">
      <FilterBar
        keyword={keyword}
        onKeywordChange={setKeyword}
        category={category}
        onCategoryChange={setCategory}
      />

      <RenderBox title="買うもの(ブラウザに保存されている)" tone="highlight">
        <ul className="flex flex-col gap-2">
          {shown.map((item) => (
            <li key={item.id}>
              <button
                type="button"
                onClick={() => toggle(item.id)}
                className="focus-ring flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left"
              >
                <span className={item.bought ? "line-through opacity-50" : ""}>
                  {item.name}
                </span>
                <span className="ml-auto text-xs text-muted-foreground">
                  {item.category}
                </span>
              </button>
            </li>
          ))}
          {shown.length === 0 && (
            <li className="text-sm text-muted-foreground">
              条件に合うものがありません
            </li>
          )}
        </ul>
      </RenderBox>

      <div className="flex gap-2">
        <Input
          placeholder="買うものを足す"
          aria-label="買うものを足す"
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
        />
        <Button size="sm" onClick={add}>
          足す
        </Button>
      </div>

      <Button
        size="sm"
        variant="outline"
        onClick={() => setItems(initialItems)}
      >
        最初の状態に戻す
      </Button>
    </div>
  );
}