Part 11 · まとめて作る
63 / 64 章
メモ帳を作る
離れた部品で値を共有すると、どこまで描き直されるのか
今度は離れた部品どうしで値を共有する画面を作ります。 メモ帳です。一覧・本文・配色の切り替えが、 それぞれ別の部品になっています。
Part 8 と Part 9 でやったことが、ここで 1 つにつながります。 テーマは「打つたびに、どこまで描き直されるか」です。
作るもの
type Memo = {
id: number;
title: string;
body: string;
};
// メモ帳の中だけの配色。アプリ全体のテーマとは別のもの
type Palette = "plain" | "warm" | "cool";部品は 4 つです。
- ヘッダー … 配色だけを使う
- 一覧 … メモの題名と、選ばれている ID を使う
- 本文 … 選ばれているメモの中身を編集する
- 配色の切り替え … 配色を変える
本文に 1 文字打ったとき、ヘッダーと配色ボタンには関係がないはずです。 配色は変えていないのですから。 そうなるかどうかを見ていきます。
types.ts
export type Memo = {
id: number;
title: string;
body: string;
};
/** メモ帳の中だけで使う配色。アプリ全体のテーマとは別のもの */
export type Palette = "plain" | "warm" | "cool";
export const paletteLabels: Record<Palette, string> = {
plain: "白",
warm: "あたたかい",
cool: "つめたい",
};
export const paletteStyles: Record<Palette, string> = {
plain: "bg-background",
warm: "bg-amber-500/10",
cool: "bg-sky-500/10",
};
export const initialMemos: Memo[] = [
{ id: 1, title: "買い物", body: "牛乳とパン" },
{ id: 2, title: "読みたい本", body: "リファクタリング" },
];まず、ひとつの Context にまとめて書く
共有したい値をぜんぶ 1 つの Context に入れます。 自然な書き方ですし、実際よく見かけます。
const NotepadContext = createContext<{
memos: Memo[];
selectedId: number;
palette: Palette;
select: (id: number) => void;
updateBody: (body: string) => void;
setPalette: (palette: Palette) => void;
}>({ ... });さらに、4 つの部品はすべて memo で 包んであります。 Part 8 でやったとおり、無駄な描き直しを止めるためです。
本文に 1 文字打ってみる
4 つとも光ります。本文に打っただけなのに、ヘッダーも一覧も配色ボタンも 描き直されています。memo で包んだのに、です。
2 つの原因が重なっている
Part 9「Context と再レンダリング」でやった 2 つが、 両方そろっています。
- 項目単位で購読できない … 配色しか使っていないヘッダーも、 「NotepadContext を購読している」としか見なされない
- value を毎回作り直している …
value={{ ... }}と その場で書いているので、 Provider が描き直されるたびに別のものになる
one-big-context.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createContext, memo, use, useState, type ReactNode } from "react";
import {
initialMemos,
paletteLabels,
paletteStyles,
type Memo,
type Palette,
} from "./types";
/** 全部ひとまとめの Context */
const NotepadContext = createContext<{
memos: Memo[];
selectedId: number;
palette: Palette;
select: (id: number) => void;
updateBody: (body: string) => void;
setPalette: (palette: Palette) => void;
}>({
memos: [],
selectedId: 1,
palette: "plain",
select: () => {},
updateBody: () => {},
setPalette: () => {},
});
function Provider({ children }: { children: ReactNode }) {
const [memos, setMemos] = useState(initialMemos);
const [selectedId, setSelectedId] = useState(1);
const [palette, setPalette] = useState<Palette>("plain");
const updateBody = (body: string) =>
setMemos((current) =>
current.map((memoItem) =>
memoItem.id === selectedId ? { ...memoItem, body } : memoItem,
),
);
// value をその場で作っている。Provider が描き直されるたびに新しくなる
return (
<NotepadContext
value={{
memos,
selectedId,
palette,
select: setSelectedId,
updateBody,
setPalette,
}}
>
{children}
</NotepadContext>
);
}
/** memo で包んであるが、Context を購読しているので効かない */
const Header = memo(function Header() {
const { palette } = use(NotepadContext);
return (
<RenderBox title="ヘッダー(配色だけ使う)">
いまの配色: {paletteLabels[palette]}
</RenderBox>
);
});
const MemoList = memo(function MemoList() {
const { memos, selectedId, select } = use(NotepadContext);
return (
<RenderBox title="一覧(本文は使わない)">
<ul className="flex flex-col gap-2">
{memos.map((memoItem) => (
<li key={memoItem.id}>
<button
type="button"
onClick={() => select(memoItem.id)}
className={`focus-ring w-full rounded-md border px-3 py-2 text-left ${
memoItem.id === selectedId ? "border-foreground/40" : ""
}`}
>
{memoItem.title}
</button>
</li>
))}
</ul>
</RenderBox>
);
});
const Editor = memo(function Editor() {
const { memos, selectedId, updateBody, palette } = use(NotepadContext);
const current = memos.find((memoItem) => memoItem.id === selectedId);
return (
<RenderBox title="本文(ここに打つ)" tone="highlight">
<Input
value={current?.body ?? ""}
onChange={(event) => updateBody(event.target.value)}
aria-label="本文"
className={paletteStyles[palette]}
/>
</RenderBox>
);
});
const PaletteButtons = memo(function PaletteButtons() {
const { palette, setPalette } = use(NotepadContext);
return (
<RenderBox title="配色の切り替え">
<div className="flex flex-wrap gap-2">
{(Object.keys(paletteLabels) as Palette[]).map((name) => (
<Button
key={name}
size="sm"
variant={palette === name ? "default" : "outline"}
onClick={() => setPalette(name)}
>
{paletteLabels[name]}
</Button>
))}
</div>
</RenderBox>
);
});
export function OneBigContext() {
return (
<Provider>
<div className="flex flex-col gap-3">
<Header />
<MemoList />
<Editor />
<PaletteButtons />
</div>
</Provider>
);
}実務では、ここでストアを使う
Context のままでも直せます。Part 9「Context と再レンダリング」 でやったとおり、関心ごとに Context を分け、useMemo で包み、memo で囲む。
ただ、部品が 4 つでこの手数です。 画面が育つほど Provider が増えて、入れ子が深くなっていきます。実務でこの規模になったら、たいていストアを使います。
export const useNotepadStore = create((set) => ({
titles: [...],
bodies: { ... },
selectedId: 1,
palette: "plain",
select: (id) => set({ selectedId: id }),
setPalette: (palette) => set({ palette }),
updateBody: (body) => set((state) => ({ ... })),
}));Provider は 1 つも出てきません。置き場所は 1 か所のままです。Zustand の章 でやったとおり、絞るのは受け取る側だからです。
受け取る側で絞る
// ヘッダー … 配色しか読まない
const palette = useNotepadStore((state) => state.palette);
// 本文 … 選ばれている 1 件の本文だけ
const body = useNotepadStore(
(state) => state.bodies[state.selectedId] ?? "",
);セレクタが返した値が前と同じかどうかで決まります。 配色を読んでいない部品は、 配色が変わってもそもそも呼ばれません。
ストアの形は、読む側に合わせて決める
ここで 1 つ、設計の判断が要ります。 メモをそのまま 1 つの配列で持つと、こうなります。
// ✕ 本文を打つと、配列ごと作り直される
memos: [{ id: 1, title: "買い物", body: "牛乳とパン" }, ...]
updateBody: (body) => set((state) => ({
memos: state.memos.map((m) => m.id === id ? { ...m, body } : m),
})),map は新しい配列を作ります。 題名は 1 文字も変わっていないのに、memos は別のものになります。題名しか読んでいない一覧まで描き直されます。
セレクタで頑張って絞ることもできますが、ストアの形を変えたほうが素直です。
// ○ 変わらないものと、変わるものを分けて持つ
titles: [{ id: 1, title: "買い物" }, ...], // 動かない
bodies: { 1: "牛乳とパン", 2: "..." }, // 打つたびに変わる
updateBody: (body) => set((state) => ({
bodies: { ...state.bodies, [state.selectedId]: body },
})),一覧は titles だけを読みます。 本文をいくら打っても titles は書き換わらないので、一覧はそもそも呼ばれません。
store.ts
import { create } from "zustand";
import { initialMemos, type Palette } from "./types";
export type NotepadStore = {
/** 一覧が読むもの。題名は変わらないので、ここは動かない */
titles: { id: number; title: string }[];
/** 本文。打つたびに変わる */
bodies: Record<number, string>;
selectedId: number;
palette: Palette;
select: (id: number) => void;
updateBody: (body: string) => void;
setPalette: (palette: Palette) => void;
};
/**
* メモ帳の状態を 1 か所に置く。
*
* 題名と本文を分けて持っているのがポイント。
* 1 つの配列にまとめると、本文を打つたびに配列全体が作り直され、
* 題名しか読まない一覧まで描き直されてしまう。
* ストアの形は、読む側に合わせて決める。
*/
export const useNotepadStore = create<NotepadStore>((set) => ({
titles: initialMemos.map((memo) => ({ id: memo.id, title: memo.title })),
bodies: Object.fromEntries(
initialMemos.map((memo) => [memo.id, memo.body]),
),
selectedId: 1,
palette: "plain",
select: (id) => set({ selectedId: id }),
updateBody: (body) =>
set((state) => ({
// 書き換わるのは bodies だけ。titles は元のまま
bodies: { ...state.bodies, [state.selectedId]: body },
})),
setPalette: (palette) => set({ palette }),
}));書き換えた版
同じように、本文に 1 文字打ってみる
光るのは本文の箱だけになりました。ヘッダーも配色ボタンも、そして一覧も動きません。
最初に「本文に打ったとき、ヘッダーと配色ボタンには関係がないはず」 と書きました。実際にはそれ以上で、一覧まで止まっています。 題名を持っている titles が 書き換わっていないからです。
今度は配色を切り替えてみてください。ヘッダー・本文・配色ボタンが光り、一覧は光りません。 一覧は配色を読んでいないからです。読んでいる部品だけが動く状態になりました。
上の ❌ 版と見比べてください。 あちらは memo で 4 つとも包んでいたのに、全部光りました。 こちらは memo を 1 つも使わずに、光る範囲がここまで狭まっています。
notepad.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useNotepadStore } from "./store";
import { paletteLabels, paletteStyles, type Palette } from "./types";
/* memo で包んでいない。セレクタで絞れば、それだけで止まる */
function Header() {
const palette = useNotepadStore((state) => state.palette);
return (
<RenderBox title="ヘッダー(配色だけ使う)">
いまの配色: {paletteLabels[palette]}
</RenderBox>
);
}
function MemoList() {
// titles は本文を打っても書き換わらない。だからここは動かない
const titles = useNotepadStore((state) => state.titles);
const selectedId = useNotepadStore((state) => state.selectedId);
const select = useNotepadStore((state) => state.select);
return (
<RenderBox title="一覧(本文は読まない)">
<ul className="flex flex-col gap-2">
{titles.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => select(item.id)}
className={`focus-ring w-full rounded-md border px-3 py-2 text-left ${
item.id === selectedId ? "border-foreground/40" : ""
}`}
>
{item.title}
</button>
</li>
))}
</ul>
</RenderBox>
);
}
function Editor() {
// 取り出すのは 1 本の文字列。配列やオブジェクトを組み立てていない
const body = useNotepadStore((state) => state.bodies[state.selectedId] ?? "");
const palette = useNotepadStore((state) => state.palette);
const updateBody = useNotepadStore((state) => state.updateBody);
return (
<RenderBox title="本文(ここに打つ)" tone="highlight">
<Input
value={body}
onChange={(event) => updateBody(event.target.value)}
aria-label="本文"
className={paletteStyles[palette]}
/>
</RenderBox>
);
}
function PaletteButtons() {
const palette = useNotepadStore((state) => state.palette);
const setPalette = useNotepadStore((state) => state.setPalette);
return (
<RenderBox title="配色の切り替え">
<div className="flex flex-wrap gap-2">
{(Object.keys(paletteLabels) as Palette[]).map((name) => (
<Button
key={name}
size="sm"
variant={palette === name ? "default" : "outline"}
onClick={() => setPalette(name)}
>
{paletteLabels[name]}
</Button>
))}
</div>
</RenderBox>
);
}
export function Notepad() {
return (
<div className="flex flex-col gap-3">
<Header />
<MemoList />
<Editor />
<PaletteButtons />
</div>
);
}ただし、最初からストアに置かない
Zustand の章 で書いたとおりです。まず useState。共有が必要になってから持ち上げる。 それでも苦しくなってから、ストア。
store.ts
import { create } from "zustand";
import { initialMemos, type Palette } from "./types";
export type NotepadStore = {
/** 一覧が読むもの。題名は変わらないので、ここは動かない */
titles: { id: number; title: string }[];
/** 本文。打つたびに変わる */
bodies: Record<number, string>;
selectedId: number;
palette: Palette;
select: (id: number) => void;
updateBody: (body: string) => void;
setPalette: (palette: Palette) => void;
};
/**
* メモ帳の状態を 1 か所に置く。
*
* 題名と本文を分けて持っているのがポイント。
* 1 つの配列にまとめると、本文を打つたびに配列全体が作り直され、
* 題名しか読まない一覧まで描き直されてしまう。
* ストアの形は、読む側に合わせて決める。
*/
export const useNotepadStore = create<NotepadStore>((set) => ({
titles: initialMemos.map((memo) => ({ id: memo.id, title: memo.title })),
bodies: Object.fromEntries(
initialMemos.map((memo) => [memo.id, memo.body]),
),
selectedId: 1,
palette: "plain",
select: (id) => set({ selectedId: id }),
updateBody: (body) =>
set((state) => ({
// 書き換わるのは bodies だけ。titles は元のまま
bodies: { ...state.bodies, [state.selectedId]: body },
})),
setPalette: (palette) => set({ palette }),
}));理解できたか確かめる
確認クイズ
memo で包んだ部品が、Context の値が変わると描き直されるのはなぜ?
確認クイズ
題名と本文を別々に持つと、何が変わる?
確認クイズ
この画面くらいの規模なら、本当はストアに置く必要がある?
one-big-context.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { createContext, memo, use, useState, type ReactNode } from "react";
import {
initialMemos,
paletteLabels,
paletteStyles,
type Memo,
type Palette,
} from "./types";
/** 全部ひとまとめの Context */
const NotepadContext = createContext<{
memos: Memo[];
selectedId: number;
palette: Palette;
select: (id: number) => void;
updateBody: (body: string) => void;
setPalette: (palette: Palette) => void;
}>({
memos: [],
selectedId: 1,
palette: "plain",
select: () => {},
updateBody: () => {},
setPalette: () => {},
});
function Provider({ children }: { children: ReactNode }) {
const [memos, setMemos] = useState(initialMemos);
const [selectedId, setSelectedId] = useState(1);
const [palette, setPalette] = useState<Palette>("plain");
const updateBody = (body: string) =>
setMemos((current) =>
current.map((memoItem) =>
memoItem.id === selectedId ? { ...memoItem, body } : memoItem,
),
);
// value をその場で作っている。Provider が描き直されるたびに新しくなる
return (
<NotepadContext
value={{
memos,
selectedId,
palette,
select: setSelectedId,
updateBody,
setPalette,
}}
>
{children}
</NotepadContext>
);
}
/** memo で包んであるが、Context を購読しているので効かない */
const Header = memo(function Header() {
const { palette } = use(NotepadContext);
return (
<RenderBox title="ヘッダー(配色だけ使う)">
いまの配色: {paletteLabels[palette]}
</RenderBox>
);
});
const MemoList = memo(function MemoList() {
const { memos, selectedId, select } = use(NotepadContext);
return (
<RenderBox title="一覧(本文は使わない)">
<ul className="flex flex-col gap-2">
{memos.map((memoItem) => (
<li key={memoItem.id}>
<button
type="button"
onClick={() => select(memoItem.id)}
className={`focus-ring w-full rounded-md border px-3 py-2 text-left ${
memoItem.id === selectedId ? "border-foreground/40" : ""
}`}
>
{memoItem.title}
</button>
</li>
))}
</ul>
</RenderBox>
);
});
const Editor = memo(function Editor() {
const { memos, selectedId, updateBody, palette } = use(NotepadContext);
const current = memos.find((memoItem) => memoItem.id === selectedId);
return (
<RenderBox title="本文(ここに打つ)" tone="highlight">
<Input
value={current?.body ?? ""}
onChange={(event) => updateBody(event.target.value)}
aria-label="本文"
className={paletteStyles[palette]}
/>
</RenderBox>
);
});
const PaletteButtons = memo(function PaletteButtons() {
const { palette, setPalette } = use(NotepadContext);
return (
<RenderBox title="配色の切り替え">
<div className="flex flex-wrap gap-2">
{(Object.keys(paletteLabels) as Palette[]).map((name) => (
<Button
key={name}
size="sm"
variant={palette === name ? "default" : "outline"}
onClick={() => setPalette(name)}
>
{paletteLabels[name]}
</Button>
))}
</div>
</RenderBox>
);
});
export function OneBigContext() {
return (
<Provider>
<div className="flex flex-col gap-3">
<Header />
<MemoList />
<Editor />
<PaletteButtons />
</div>
</Provider>
);
}この章のまとめ
memoは props しか見ない。Context 経由の値は止められない- Context は項目単位では購読できない。 置き場所ごと分けるしかない
- ストアなら置き場所は 1 つのまま、 受け取る側のセレクタで絞る
- 一緒に変わらないものは、一緒に持たない。 題名と本文を分ければ、一覧は本文に反応しなくなる
- セレクタで止まるので、
memoもuseMemoも要らない - そして、共有が必要になるまでストアに置かない
notepad.tsx
"use client";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useNotepadStore } from "./store";
import { paletteLabels, paletteStyles, type Palette } from "./types";
/* memo で包んでいない。セレクタで絞れば、それだけで止まる */
function Header() {
const palette = useNotepadStore((state) => state.palette);
return (
<RenderBox title="ヘッダー(配色だけ使う)">
いまの配色: {paletteLabels[palette]}
</RenderBox>
);
}
function MemoList() {
// titles は本文を打っても書き換わらない。だからここは動かない
const titles = useNotepadStore((state) => state.titles);
const selectedId = useNotepadStore((state) => state.selectedId);
const select = useNotepadStore((state) => state.select);
return (
<RenderBox title="一覧(本文は読まない)">
<ul className="flex flex-col gap-2">
{titles.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => select(item.id)}
className={`focus-ring w-full rounded-md border px-3 py-2 text-left ${
item.id === selectedId ? "border-foreground/40" : ""
}`}
>
{item.title}
</button>
</li>
))}
</ul>
</RenderBox>
);
}
function Editor() {
// 取り出すのは 1 本の文字列。配列やオブジェクトを組み立てていない
const body = useNotepadStore((state) => state.bodies[state.selectedId] ?? "");
const palette = useNotepadStore((state) => state.palette);
const updateBody = useNotepadStore((state) => state.updateBody);
return (
<RenderBox title="本文(ここに打つ)" tone="highlight">
<Input
value={body}
onChange={(event) => updateBody(event.target.value)}
aria-label="本文"
className={paletteStyles[palette]}
/>
</RenderBox>
);
}
function PaletteButtons() {
const palette = useNotepadStore((state) => state.palette);
const setPalette = useNotepadStore((state) => state.setPalette);
return (
<RenderBox title="配色の切り替え">
<div className="flex flex-wrap gap-2">
{(Object.keys(paletteLabels) as Palette[]).map((name) => (
<Button
key={name}
size="sm"
variant={palette === name ? "default" : "outline"}
onClick={() => setPalette(name)}
>
{paletteLabels[name]}
</Button>
))}
</div>
</RenderBox>
);
}
export function Notepad() {
return (
<div className="flex flex-col gap-3">
<Header />
<MemoList />
<Editor />
<PaletteButtons />
</div>
);
}