本文へスキップ
メモ帳を作る

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 でやったとおり、無駄な描き直しを止めるためです。

ひとつの Context にまとめた版うまくいかない例

本文に 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 つのまま、 受け取る側のセレクタで絞る
  • 一緒に変わらないものは、一緒に持たない。 題名と本文を分ければ、一覧は本文に反応しなくなる
  • セレクタで止まるので、memouseMemo も要らない
  • そして、共有が必要になるまでストアに置かない

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>
  );
}