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);絞り込んで、チェックを付けて、そのあと再読み込みする
絞り込んでから再読み込みしてみてください
動きます。ちゃんと絞り込めますし、チェックも付きます。 問題はページを再読み込みしたときです。
- 買うものが最初に戻ります。 さっき足したものも、チェックも消えます
- 絞り込みも戻ります。 人に「野菜だけ見せたい」と思っても、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 つとも性質が違います。
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>
);
}