state は最小限にする

Part 4 · 状態 (state)

state は最小限にする

計算で求まる値を state にしてはいけない

useState が使えるようになると、 画面に出したいものを片っ端から state にしたくなります。

ですが state は増やすほど壊れやすくなります。 持つべきものと、持ってはいけないものがあります。

判断の基準はひとつだけです。他の state から計算できるなら、state にしない。

同じことを 2 か所で持つと、ずれる

リストと、その件数を表示する画面を考えます。 素直に作ると、両方を state にしてしまいがちです。

const [items, setItems] = useState(["りんご"]);
const [count, setCount] = useState(1);

こうすると、items を変えるたびに count も直す必要が出てきます。下のデモで「空にする」を押してみてください。

件数も state にした場合うまくいかない例

中身は空なのに、件数だけ残る

件数: 1

中身: りんご

中身は空なのに、件数は残ったままです。clear の中で setCount(0) を書き忘れたためです。

これは「うっかり」で片付く話ではありません。2 か所を手で合わせ続けなければならない作りにした時点で、 いつか必ず起きます。更新する場所が増えるほど、確率は上がります。

duplicated-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DuplicatedState() {
  const [items, setItems] = useState(["りんご"]);
  // 件数も state として別に持ってしまっている
  const [count, setCount] = useState(1);

  const add = () => {
    setItems([...items, "りんご"]);
    setCount(count + 1); // 2 か所を手で合わせている
  };

  const clear = () => {
    setItems([]);
    // ここで setCount(0) を書き忘れた。件数だけが残る
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p>件数: {count}</p>
        <p className="text-muted-foreground">
          中身: {items.length === 0 ? "(空)" : items.join(", ")}
        </p>
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={add}>
          増やす
        </Button>
        <Button size="sm" variant="outline" onClick={clear}>
          空にする
        </Button>
      </div>
    </div>
  );
}

計算できるものは、計算する

件数は items から必ず求まります。 なら、持つ必要はありません。

const [items, setItems] = useState(["りんご"]);

// state ではなく、ただの変数
const count = items.length;

コンポーネントは state が変わるたびに実行し直されるので、この行も毎回計算し直されます。 つまり常に最新です。

件数を計算で出した場合直した例

更新の書き忘れが起こりえない

件数: 1

中身: りんご

clear のような処理も、setItems([]) だけで済みます。合わせる相手がいないので、ずれようがありません。

derived-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DerivedState() {
  // state はこれだけ
  const [items, setItems] = useState(["りんご"]);

  // 件数は state にせず、毎回計算する。ずれようがない
  const count = items.length;

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p>件数: {count}</p>
        <p className="text-muted-foreground">
          中身: {items.length === 0 ? "(空)" : items.join(", ")}
        </p>
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={() => setItems([...items, "りんご"])}>
          増やす
        </Button>
        <Button size="sm" variant="outline" onClick={() => setItems([])}>
          空にする
        </Button>
      </div>
    </div>
  );
}

よくある「state にしなくていいもの」

const [items, setItems] = useState<Item[]>([]);
const [keyword, setKeyword] = useState("");

// どれも計算で出せる。state にしない
const count = items.length;
const isEmpty = items.length === 0;
const total = items.reduce((sum, item) => sum + item.price, 0);
const found = items.filter((item) => item.name.includes(keyword));
const canSubmit = keyword.trim() !== "";

ここで state なのは itemskeyword の 2 つだけです。残りは全部そこから決まります。

「この画面の状態は何か」を考えるとき、これ以上減らせない情報は何かという問い方をすると、 自然と最小限になります。

derived-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DerivedState() {
  // state はこれだけ
  const [items, setItems] = useState(["りんご"]);

  // 件数は state にせず、毎回計算する。ずれようがない
  const count = items.length;

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p>件数: {count}</p>
        <p className="text-muted-foreground">
          中身: {items.length === 0 ? "(空)" : items.join(", ")}
        </p>
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={() => setItems([...items, "りんご"])}>
          増やす
        </Button>
        <Button size="sm" variant="outline" onClick={() => setItems([])}>
          空にする
        </Button>
      </div>
    </div>
  );
}

理解できたか確かめる

確認クイズ

items と、その件数 count を両方 state にすると何が起きる?

確認クイズ

次のうち、state にすべきなのはどれ?

derived-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DerivedState() {
  // state はこれだけ
  const [items, setItems] = useState(["りんご"]);

  // 件数は state にせず、毎回計算する。ずれようがない
  const count = items.length;

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p>件数: {count}</p>
        <p className="text-muted-foreground">
          中身: {items.length === 0 ? "(空)" : items.join(", ")}
        </p>
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={() => setItems([...items, "りんご"])}>
          増やす
        </Button>
        <Button size="sm" variant="outline" onClick={() => setItems([])}>
          空にする
        </Button>
      </div>
    </div>
  );
}

この章のまとめ

  • 他の state から計算できるものは、state にしない
  • 二重に持つと、更新のたびに手で合わせる必要が生まれ、いつかずれる
  • 計算した値はコンポーネントの実行のたびに求め直されるので、常に最新
  • 件数・合計・絞り込み結果・押せるかどうかは、たいてい計算で出せる
  • state を決めるときは「これ以上減らせない情報は何か」と考える

derived-state.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";

export function DerivedState() {
  // state はこれだけ
  const [items, setItems] = useState(["りんご"]);

  // 件数は state にせず、毎回計算する。ずれようがない
  const count = items.length;

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p>件数: {count}</p>
        <p className="text-muted-foreground">
          中身: {items.length === 0 ? "(空)" : items.join(", ")}
        </p>
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={() => setItems([...items, "りんご"])}>
          増やす
        </Button>
        <Button size="sm" variant="outline" onClick={() => setItems([])}>
          空にする
        </Button>
      </div>
    </div>
  );
}