本文へスキップ
Context と再レンダリング

Part 9 · state を共有する

49 / 64

Context と再レンダリング

1 つの Context に詰め込むと何が起きるか

Context は便利なので、つい「アプリの状態を全部ここに入れよう」と考えたくなります。

それをやると、関係のない部品まで一斉に描き直されます。 しかも気づきにくい形で。

ひとつにまとめた場合

countname を、 ひとつの Context に入れてあります。 そして下には、それぞれ片方しか使っていない部品が並んでいます。

「count を増やす」を押してください。

count と name を同じ Context に入れるうまくいかない例

count を押すと、name の部品まで光る

count だけを使う部品
count: 0
name だけを使う部品
name: さとう

name しか使っていない部品まで光ります。name の値はまったく変わっていないのに、です。

large-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useState } from "react";

type Store = {
  count: number;
  setCount: (update: (current: number) => number) => void;
  name: string;
  setName: (update: (current: string) => string) => void;
};

// count も name も、ひとつの置き場所にまとめている
const LargeContext = createContext<Store>({} as Store);

function LargeProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("さとう");

  return (
    <LargeContext value={{ count, setCount, name, setName }}>
      {children}
    </LargeContext>
  );
}

// count しか使っていない
function CountDisplay() {
  const { count } = use(LargeContext);
  return <RenderBox title="count だけを使う部品">count: {count}</RenderBox>;
}

// name しか使っていない
function NameDisplay() {
  const { name } = use(LargeContext);
  return <RenderBox title="name だけを使う部品">name: {name}</RenderBox>;
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount, setName } = use(LargeContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function LargeContextDemo() {
  return (
    <LargeProvider>
      <div className="flex flex-col gap-4">
        <Controls />
        <CountDisplay />
        <NameDisplay />
      </div>
    </LargeProvider>
  );
}

なぜ関係ない部品まで描き直されるのか

Part 7 で「再レンダリングの理由は、いまのところ 2 つ」と書きました。これが 3 つめです。自分の state でも、親の描き直しでもなく、見に行っている Context が変わったとき

理由は 2 つ重なっています。

1. Context は「項目ごと」に見てくれない

use(LargeContext) は、その Context 全体を購読します。 「name だけを見ている」と書いたつもりでも、React はそう解釈しません。

// name だけ取り出しているように見えるが…
const { name } = use(LargeContext);

// React から見ると「LargeContext を購読している」だけ

React が見ているのはvalue に渡したものが、前と別のものになったかどうかだけです。 別のものになっていれば、購読している部品はすべて描き直されます。どの項目を使っているかは、見ていません。

2. value のオブジェクトが毎回新しく作られる

<LargeContext value={{ count, setCount, name, setName }}>

この { ... } は、 描き直されるたびに新しいオブジェクトになります。 Part 8 で何度も見た形です。

count が変わって Provider が描き直されると、 中身が同じでも value は別のものになります。 購読している側から見れば「変わった」ことになります。

large-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useState } from "react";

type Store = {
  count: number;
  setCount: (update: (current: number) => number) => void;
  name: string;
  setName: (update: (current: string) => string) => void;
};

// count も name も、ひとつの置き場所にまとめている
const LargeContext = createContext<Store>({} as Store);

function LargeProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("さとう");

  return (
    <LargeContext value={{ count, setCount, name, setName }}>
      {children}
    </LargeContext>
  );
}

// count しか使っていない
function CountDisplay() {
  const { count } = use(LargeContext);
  return <RenderBox title="count だけを使う部品">count: {count}</RenderBox>;
}

// name しか使っていない
function NameDisplay() {
  const { name } = use(LargeContext);
  return <RenderBox title="name だけを使う部品">name: {name}</RenderBox>;
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount, setName } = use(LargeContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function LargeContextDemo() {
  return (
    <LargeProvider>
      <div className="flex flex-col gap-4">
        <Controls />
        <CountDisplay />
        <NameDisplay />
      </div>
    </LargeProvider>
  );
}

関心ごとに分ける

解決は単純です。置き場所を分けます。

// ひとつにまとめる代わりに
const CountContext = createContext(...);
const NameContext = createContext(...);
Context を 2 つに分ける直した例

count を押しても、name の部品は光らない

count だけを使う部品
count: 0
name だけを使う部品
name: さとう

片方だけが光るようになりました。name の部品は NameContext しか購読していないので、count の変化は届きません。

split-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useMemo, useState } from "react";

// 置き場所を関心ごとに分ける
const CountContext = createContext<{
  count: number;
  setCount: (update: (current: number) => number) => void;
}>({ count: 0, setCount: () => {} });

const NameContext = createContext<{
  name: string;
  setName: (update: (current: string) => string) => void;
}>({ name: "", setName: () => {} });

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  // count が変わったときだけ、新しい value を作る
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

function NameProvider({ children }: { children: ReactNode }) {
  const [name, setName] = useState("さとう");
  const value = useMemo(() => ({ name, setName }), [name]);
  return <NameContext value={value}>{children}</NameContext>;
}

// count の置き場所だけを見る
function CountDisplay() {
  const { count } = use(CountContext);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

// name の置き場所だけを見る
function NameDisplay() {
  const { name } = use(NameContext);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount } = use(CountContext);
  const { setName } = use(NameContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function SplitContextDemo() {
  return (
    <CountProvider>
      <NameProvider>
        <div className="flex flex-col gap-4">
          <Controls />
          <CountDisplay />
          <NameDisplay />
        </div>
      </NameProvider>
    </CountProvider>
  );
}

分けただけでは、まだ半分

ここまでで原因 1(項目単位で購読できない)は消えました。 ですが原因 2 はまだ残っています。value{ count, setCount } と直接書くと、 Provider が描き直されるたびに新しいオブジェクトになります。

これを止める道具は、Part 8 でもう手に入れています。useMemo です。

// ✕ 描き直されるたびに、新しいオブジェクト
return <CountContext value={{ count, setCount }}>{children}</CountContext>;

// ○ count が変わったときだけ、新しいオブジェクト
const value = useMemo(() => ({ count, setCount }), [count]);
return <CountContext value={value}>{children}</CountContext>;

いまのデモでは、count が変われば どのみち購読側は描き直されるので、この 1 行で見た目が変わるわけではありません。 効いてくるのは、Provider が別の理由で描き直されたときです。

たとえば Provider に別の state が増えたとき。value を包んでいなければ、count は 1 ミリも変わっていないのにcount を使う部品が全部描き直されます。 包んであれば、何も起きません。

split-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useMemo, useState } from "react";

// 置き場所を関心ごとに分ける
const CountContext = createContext<{
  count: number;
  setCount: (update: (current: number) => number) => void;
}>({ count: 0, setCount: () => {} });

const NameContext = createContext<{
  name: string;
  setName: (update: (current: string) => string) => void;
}>({ name: "", setName: () => {} });

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  // count が変わったときだけ、新しい value を作る
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

function NameProvider({ children }: { children: ReactNode }) {
  const [name, setName] = useState("さとう");
  const value = useMemo(() => ({ name, setName }), [name]);
  return <NameContext value={value}>{children}</NameContext>;
}

// count の置き場所だけを見る
function CountDisplay() {
  const { count } = use(CountContext);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

// name の置き場所だけを見る
function NameDisplay() {
  const { name } = use(NameContext);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount } = use(CountContext);
  const { setName } = use(NameContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function SplitContextDemo() {
  return (
    <CountProvider>
      <NameProvider>
        <div className="flex flex-col gap-4">
          <Controls />
          <CountDisplay />
          <NameDisplay />
        </div>
      </NameProvider>
    </CountProvider>
  );
}

Provider が children を受け取っている理由

デモの Provider は、こういう形をしています。

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

これは Part 8 の「children で切り離す」と同じ形です。 そして、この形でなければデモが成立しません

もし Provider の中に子を直接書いていたら、count が変わって Provider が描き直されるたびに、Context とは関係なく子も全部描き直されます。 それでは「Context を分けた効果」が見えません。

children として外から渡すことで、Context による描き直しだけを取り出して観察できています。

split-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useMemo, useState } from "react";

// 置き場所を関心ごとに分ける
const CountContext = createContext<{
  count: number;
  setCount: (update: (current: number) => number) => void;
}>({ count: 0, setCount: () => {} });

const NameContext = createContext<{
  name: string;
  setName: (update: (current: string) => string) => void;
}>({ name: "", setName: () => {} });

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  // count が変わったときだけ、新しい value を作る
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

function NameProvider({ children }: { children: ReactNode }) {
  const [name, setName] = useState("さとう");
  const value = useMemo(() => ({ name, setName }), [name]);
  return <NameContext value={value}>{children}</NameContext>;
}

// count の置き場所だけを見る
function CountDisplay() {
  const { count } = use(CountContext);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

// name の置き場所だけを見る
function NameDisplay() {
  const { name } = use(NameContext);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount } = use(CountContext);
  const { setName } = use(NameContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function SplitContextDemo() {
  return (
    <CountProvider>
      <NameProvider>
        <div className="flex flex-col gap-4">
          <Controls />
          <CountDisplay />
          <NameDisplay />
        </div>
      </NameProvider>
    </CountProvider>
  );
}

どう分けるか

分ける基準は「一緒に変わるかどうか」です。

  • 一緒に変わるものは同じ Context でよい (ユーザー情報の名前とメールアドレスなど)
  • 別々に変わるものは分ける (ユーザー情報とテーマ設定など)
  • 更新が頻繁なものは必ず分ける。 巻き込む回数がそのまま増える

値と更新関数を分ける手もある

const CountContext = createContext(0);           // 値
const SetCountContext = createContext(() => {}); // 更新関数

「更新はするが表示はしない」部品は珍しくありません。 そういう部品は SetCountContext だけを購読すれば、値が変わっても描き直されなくなります

split-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useMemo, useState } from "react";

// 置き場所を関心ごとに分ける
const CountContext = createContext<{
  count: number;
  setCount: (update: (current: number) => number) => void;
}>({ count: 0, setCount: () => {} });

const NameContext = createContext<{
  name: string;
  setName: (update: (current: string) => string) => void;
}>({ name: "", setName: () => {} });

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  // count が変わったときだけ、新しい value を作る
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

function NameProvider({ children }: { children: ReactNode }) {
  const [name, setName] = useState("さとう");
  const value = useMemo(() => ({ name, setName }), [name]);
  return <NameContext value={value}>{children}</NameContext>;
}

// count の置き場所だけを見る
function CountDisplay() {
  const { count } = use(CountContext);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

// name の置き場所だけを見る
function NameDisplay() {
  const { name } = use(NameContext);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount } = use(CountContext);
  const { setName } = use(NameContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function SplitContextDemo() {
  return (
    <CountProvider>
      <NameProvider>
        <div className="flex flex-col gap-4">
          <Controls />
          <CountDisplay />
          <NameDisplay />
        </div>
      </NameProvider>
    </CountProvider>
  );
}

理解できたか確かめる

確認クイズ

const { name } = use(LargeContext) と書けば、name が変わったときだけ描き直される?

確認クイズ

Provider が children を受け取る形にする理由は?

large-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useState } from "react";

type Store = {
  count: number;
  setCount: (update: (current: number) => number) => void;
  name: string;
  setName: (update: (current: string) => string) => void;
};

// count も name も、ひとつの置き場所にまとめている
const LargeContext = createContext<Store>({} as Store);

function LargeProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("さとう");

  return (
    <LargeContext value={{ count, setCount, name, setName }}>
      {children}
    </LargeContext>
  );
}

// count しか使っていない
function CountDisplay() {
  const { count } = use(LargeContext);
  return <RenderBox title="count だけを使う部品">count: {count}</RenderBox>;
}

// name しか使っていない
function NameDisplay() {
  const { name } = use(LargeContext);
  return <RenderBox title="name だけを使う部品">name: {name}</RenderBox>;
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount, setName } = use(LargeContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function LargeContextDemo() {
  return (
    <LargeProvider>
      <div className="flex flex-col gap-4">
        <Controls />
        <CountDisplay />
        <NameDisplay />
      </div>
    </LargeProvider>
  );
}

この章のまとめ

  • Context は項目ごとではなく全体を購読する。 使っていない項目の変化でも描き直される
  • value のオブジェクトは毎回新しく作られるので、Provider が描き直されれば必ず「変わった」ことになる
  • 対処は一緒に変わるものだけ、同じ箱に入れること。基準は「一緒に変わるかどうか」
  • Provider はchildren を受け取る薄い部品にする。 中に直接書くと分割の効果が半減する
  • ただし、やりすぎない。実際に遅くなってから分ける

split-context.tsx

"use client";

import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { createContext, type ReactNode, use, useMemo, useState } from "react";

// 置き場所を関心ごとに分ける
const CountContext = createContext<{
  count: number;
  setCount: (update: (current: number) => number) => void;
}>({ count: 0, setCount: () => {} });

const NameContext = createContext<{
  name: string;
  setName: (update: (current: string) => string) => void;
}>({ name: "", setName: () => {} });

function CountProvider({ children }: { children: ReactNode }) {
  const [count, setCount] = useState(0);
  // count が変わったときだけ、新しい value を作る
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountContext value={value}>{children}</CountContext>;
}

function NameProvider({ children }: { children: ReactNode }) {
  const [name, setName] = useState("さとう");
  const value = useMemo(() => ({ name, setName }), [name]);
  return <NameContext value={value}>{children}</NameContext>;
}

// count の置き場所だけを見る
function CountDisplay() {
  const { count } = use(CountContext);
  return (
    <RenderBox title="count だけを使う部品" tone="highlight">
      count: {count}
    </RenderBox>
  );
}

// name の置き場所だけを見る
function NameDisplay() {
  const { name } = use(NameContext);
  return (
    <RenderBox title="name だけを使う部品" tone="highlight">
      name: {name}
    </RenderBox>
  );
}

function Controls() {
  // 更新するだけなので、値そのものは受け取らない
  const { setCount } = use(CountContext);
  const { setName } = use(NameContext);

  return (
    <div className="flex flex-wrap gap-2">
      <Button size="sm" onClick={() => setCount((current) => current + 1)}>
        count を増やす
      </Button>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setName((current) => (current === "さとう" ? "すずき" : "さとう"))}
      >
        name を変える
      </Button>
    </div>
  );
}

export function SplitContextDemo() {
  return (
    <CountProvider>
      <NameProvider>
        <div className="flex flex-col gap-4">
          <Controls />
          <CountDisplay />
          <NameDisplay />
        </div>
      </NameProvider>
    </CountProvider>
  );
}