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: 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);
});
};連打してみる。数字はどう動くか
連打すると、数字だけが軽やかに増えていきます。 重い計算のほうは薄くなって「計算中…」と出たまま、手を止めたところで追いつきます。
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: "回" }), []);count を押す。name は変わっていない
count: 0
どちらの子も 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>
);
}