本文へスキップ
useMemo

Part 8 · パフォーマンス

43 / 64

useMemo

重い計算を毎回やらせない

コンポーネントは、state が変わるたびに頭から全部実行し直されます。 途中に重い計算があれば、その計算も毎回走ります。

関係のない state が変わっただけでも、です。

useMemo は、前の計算結果を覚えておいて使い回すための道具です。

関係ない操作で、計算が走る

下のデモには、わざと時間のかかる計算を入れてあります。入力欄に文字を打ってみてください。

毎回計算するうまくいかない例

1 文字打つたびに引っかかる

count: 0

計算結果: 6000000

打つたびに一瞬固まります。入力は計算とまったく関係がないのに、です。

理由は単純で、keyword が変わるとコンポーネントが実行し直され、 その途中にある計算の行も、また実行されるからです。

const total = heavyCalculation(count); // 描き直しのたびに走る

no-memo.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 { heavyCalculation } from "./heavy";

export function NoMemo() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState("");

  // 描き直されるたびに、毎回この計算が走る
  const total = heavyCalculation(count);

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p className="font-mono">count: {count}</p>
        <p className="font-mono text-muted-foreground">計算結果: {total}</p>
      </div>

      <Button size="sm" onClick={() => setCount((c) => c + 1)}>
        count を増やす
      </Button>

      <Input
        placeholder="ここに文字を打ってみる(計算とは無関係)"
        aria-label="ここに文字を打ってみる(計算とは無関係)"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </div>
  );
}

結果を覚えておく

useMemo で包み、いつ計算し直すかを伝えます。

// 変更前
const total = heavyCalculation(count);

// 変更後
const total = useMemo(() => heavyCalculation(count), [count]);

依存配列は useEffect と同じ考え方です。ここに書いた値が変わったときだけ計算し直し、 それ以外は前の結果をそのまま返します。

count が変わったときだけ計算する直した例

入力はなめらか。count を押したときだけ待たされる

count: 0

計算結果: 6000000

入力がなめらかになりました。「count を増やす」を押したときだけ、一瞬待たされます。 計算し直す必要があるので、ここは減らしようがありません。

with-memo.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { heavyCalculation } from "./heavy";

export function WithMemo() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState("");

  // count が変わったときだけ計算し直す。それ以外は前の結果を使い回す
  const total = useMemo(() => heavyCalculation(count), [count]);

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p className="font-mono">count: {count}</p>
        <p className="font-mono text-muted-foreground">計算結果: {total}</p>
      </div>

      <Button size="sm" onClick={() => setCount((c) => c + 1)}>
        count を増やす
      </Button>

      <Input
        placeholder="ここに文字を打ってみる(計算とは無関係)"
        aria-label="ここに文字を打ってみる(計算とは無関係)"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </div>
  );
}

それでも待たされるときは

useMemo で減らせるのは「しなくてよい計算」だけです。 本当に必要な計算そのものは、速くなりません。

では、その 1 回の重さはどうにもならないのか。計算を速くすることはできませんが、 画面を止めないことはできます。

const [isPending, startTransition] = useTransition();

const increase = () => {
  // 押した手応えは、すぐ返す
  setCount((current) => current + 1);

  // 重いほうは「急がなくていい」と伝える
  startTransition(() => {
    setTarget((current) => current + 1);
  });
};
急ぐものと、急がないものを分ける

連打してみる。数字はどう動くか

すぐ反応するほう
count: 0
重い計算のほう
計算結果: 6000000

連打すると、数字だけが軽やかに増えていきます。 重い計算のほうは薄くなって「計算中…」と出たまま、手を止めたところで追いつきます

startTransition で包んだ更新は、後回しにしてよいと React に伝わります。 急ぎの更新(押した手応え)が先に処理され、 重いほうは途中で捨ててやり直せます。 だから連打しても引っかかりません。

transition.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { useMemo, useState, useTransition } from "react";
import { heavyCalculation } from "./heavy";

export function Transition() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  // 重い計算に使う値。count から遅れて追いつく
  const [target, setTarget] = useState(0);
  const [isPending, startTransition] = useTransition();

  const result = useMemo(() => heavyCalculation(target), [target]);

  const increase = () => {
    // 押した手応え(数字の更新)は、すぐ反映する
    setCount((current) => current + 1);

    // 重い計算のほうは「急がなくていい」と伝える
    startTransition(() => {
      setTarget((current) => current + 1);
    });
  };

  return (
    <div className="flex flex-col gap-3">
      <Button size="sm" onClick={increase}>
        count を増やす(連打してみる)
      </Button>

      <RenderBox title="すぐ反応するほう" tone="highlight">
        count: {count}
      </RenderBox>

      <RenderBox title="重い計算のほう">
        <span className={isPending ? "opacity-50" : ""}>
          計算結果: {result}
          {isPending && "(計算中…)"}
        </span>
      </RenderBox>
    </div>
  );
}

使ってはいけない場面のほうが多い

ここまで読むと便利に見えますが、ほとんどの計算に useMemo は要りません。

このデモの計算は1200 万回のループです。 こんな計算は、ふつうのアプリにはまず出てきません。

// これらに useMemo は不要。速すぎて差が出ない
const total = price * quantity;
const fullName = firstName + lastName;
const found = items.filter((item) => item.done);   // 数百件程度なら不要

heavy.ts

/**
 * わざと時間のかかる計算。
 * 実際にこれくらい待たされると、無駄な再計算がどれだけ効くか体で分かる。
 */
export const heavyCalculation = (count: number) => {
  let total = 0;

  for (let i = 0; i < 12_000_000; i++) {
    total += i % (count + 2);
  }

  return total;
};

もうひとつの使い道:同じものを渡し続ける

ここまでは「重い計算を省く」話でした。 ですが実務では、もうひとつの使い道のほうがよく出てきます。 計算が重いからではなく、前と同じものを渡し続けるために使う、という使い方です。

memo の章で、包んだのに効かない例を見ました。 原因は「毎回新しいオブジェクトを渡していること」でした。 あのとき外に出して直しましたが、state から組み立てるものは外に出せません

その場合に使うのが、これです。

// ✕ 毎回新しいオブジェクト → memo が効かない / effect が毎回動く
const options = { unit: "回" };

// ○ 同じものを使い回す
const options = useMemo(() => ({ unit: "回" }), []);
同じ子に、2 通りの渡し方をする

count を押す。name は変わっていない

count: 0

そのまま渡す
さとう さん
useMemo で包んで渡す
さとう さん

どちらの子も memo で包んであり、name は一度も変わっていません。 それでも上の箱だけが光ります。 渡しているものが毎回別物だからです。

stable-object.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { RenderBox } from "@/components/lesson/render-box";
import { Button } from "@/components/ui/button";
import { memo, useMemo, useState } from "react";

const Card = memo(function Card({
  user,
  title,
}: {
  user: { name: string };
  title: string;
}) {
  return (
    <RenderBox title={title} tone="highlight">
      {user.name} さん
    </RenderBox>
  );
});

export function StableObject() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  const [name] = useState("さとう");

  // ✕ 描き直されるたびに、新しいオブジェクト
  const plain = { name };

  // ○ name が変わったときだけ、新しいオブジェクト
  const stable = useMemo(() => ({ name }), [name]);

  return (
    <div className="flex flex-col gap-4">
      <p className="rounded-md border p-3 font-mono">count: {count}</p>

      <Button size="sm" onClick={() => setCount((c) => c + 1)}>
        count を増やす(name は変わらない)
      </Button>

      <Card user={plain} title="そのまま渡す" />
      <Card user={stable} title="useMemo で包んで渡す" />
    </div>
  );
}

理解できたか確かめる

確認クイズ

useMemo は何をする道具?

確認クイズ

const fullName = firstName + lastName; を useMemo で包むべき?

with-memo.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { heavyCalculation } from "./heavy";

export function WithMemo() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState("");

  // count が変わったときだけ計算し直す。それ以外は前の結果を使い回す
  const total = useMemo(() => heavyCalculation(count), [count]);

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p className="font-mono">count: {count}</p>
        <p className="font-mono text-muted-foreground">計算結果: {total}</p>
      </div>

      <Button size="sm" onClick={() => setCount((c) => c + 1)}>
        count を増やす
      </Button>

      <Input
        placeholder="ここに文字を打ってみる(計算とは無関係)"
        aria-label="ここに文字を打ってみる(計算とは無関係)"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </div>
  );
}

この章のまとめ

  • コンポーネントは毎回頭から実行される。途中の計算も毎回走る
  • useMemo は、依存配列が変わったときだけ計算し直す
  • ほとんどの計算には要らない。包むこと自体にコストがあり、軽い計算では逆効果
  • 速さ以外に、毎回同じものを渡し続ける目的でも使う

with-memo.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useMemo, useState } from "react";
import { heavyCalculation } from "./heavy";

export function WithMemo() {
  useTrackDemoRender();

  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState("");

  // count が変わったときだけ計算し直す。それ以外は前の結果を使い回す
  const total = useMemo(() => heavyCalculation(count), [count]);

  return (
    <div className="flex flex-col gap-4">
      <div className="rounded-md border p-3">
        <p className="font-mono">count: {count}</p>
        <p className="font-mono text-muted-foreground">計算結果: {total}</p>
      </div>

      <Button size="sm" onClick={() => setCount((c) => c + 1)}>
        count を増やす
      </Button>

      <Input
        placeholder="ここに文字を打ってみる(計算とは無関係)"
        aria-label="ここに文字を打ってみる(計算とは無関係)"
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
      />
    </div>
  );
}