概要
Zustand Test は 1 文字入力するたびに patchFormData → storeSync → 各 Monitor の再描画 が走ります。フィールド数や Monitor の数が増えると、入力中の体感が悪化することがあります。
ここで重要なのは 「まず計測する」 ことです。selector の絞り込み・useShallow・throttle のいずれを足すかは、計測した結果で判断します。
| 手段 | いつ使う |
|---|---|
useShallow / 個別 selector | Monitor の再描画範囲が広いとき |
subscribeWithSelector の equalityFn | storeSync 側で発火しすぎているとき |
watch 側で throttle | 文字入力が高頻度すぎるとき |
差分検知して set を 早期 return | 実値が変わらないのに書き込まれているとき |
本記事は計測 → 抑制の流れを 1 通り扱います。
インストール
npm install react-hook-form zustand
throttle を入れるなら lodash-es などの実装を使うか、自前で 5 行書いても済みます。
まず計測する: performance.mark
watch のコールバックの前後で performance.mark を打ち、Chrome DevTools の Performance タブ で並べて見るのが手早い計測です。
"use client";
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { useSettingsStore } from "@/store/settingsStore";
import type { FormData } from "@/schemas/formSchema";
export function FormWatcher() {
const { watch } = useFormContext<FormData>();
const patch = useSettingsStore((s) => s.patchFormData);
useEffect(() => {
const sub = watch((value) => {
performance.mark("watch:start");
patch({
name: value.name ?? "",
email: value.email ?? "",
rate: Math.min(100, Math.max(0, Number(value.rate) || 0)),
});
performance.mark("watch:end");
performance.measure("watch:patch", "watch:start", "watch:end");
});
return () => sub.unsubscribe();
}, [watch, patch]);
return null;
}
performance.measure で名前を付けると、DevTools の Timings に watch:patch という横棒で並びます。1 回あたり何 ms かかっているかが見えます。
数字の目安
経験則として、patch → storeSync → Monitor 再描画 の 1 サイクルは普通のフォームで 0.3〜2ms 程度です。これを大きく超える(5ms 以上)なら抑制を検討します。
| 1サイクル所要 | 状況 | 推奨アクション |
|---|---|---|
| 〜 1 ms | 通常 | 何もしない |
| 1〜3 ms | やや重い | selector 絞り込み・useShallow を再点検 |
| 3〜10 ms | 重い | storeSync の発火を equalityFn で抑える |
| 10 ms 〜 | 入力体感に影響 | watch 側で throttle |
「先に throttle を入れる」のは順序として不適切です。再描画範囲を狭めるだけで解決することの方が多いためです。
早期 return で空書き込みを防ぐ
patch({ name: "" }) のように 同じ値を再書き込み していると、selector の比較は等価でも subscribe のトリガーは引かれます。store 側で 値が変わったときだけ set に進む形にすると、ノイズが減ります。
// src/store/settingsStore.ts
patchFormData: (partial) => {
const current = get();
const changed: Partial<FormData> = {};
for (const k of Object.keys(partial) as (keyof FormData)[]) {
if (current[k] !== partial[k]) {
(changed as Record<string, unknown>)[k] = partial[k];
}
}
if (Object.keys(changed).length === 0) return;
set(changed, false, "patchFormData");
},
差分が空なら set を呼ばず、DevTools のタイムラインも汚れません。
storeSync 側で equalityFn を強める
subscribeWithSelector の equalityFn を より厳格な比較 に差し替えると、storeSync の発火回数を減らせます。
たとえば「rate を整数に丸めた値」でしか profile を更新したくない、という場合は selector 側で丸めれば、丸めた結果が同じなら発火しません。
useSettingsStore.subscribe(
(s) => Math.floor(s.rate), // selector で丸める
(rateInt) => {
useProfileStore.getState().updateFromRate(rateInt);
}
);
selector 内で値を変形 → 戻り値の同一判定で発火制御、というパターンは覚えておくと汎用的に使えます。
どうしても重ければ throttle
それでも watch のコストが高い場合(複雑なフォーム / 重い派生)に限り、watch の callback に throttle を被せます。
import { throttle } from "lodash-es";
import { useEffect, useMemo } from "react";
export function FormWatcher() {
const { watch } = useFormContext<FormData>();
const patch = useSettingsStore((s) => s.patchFormData);
const throttled = useMemo(
() => throttle((value: Partial<FormData>) => {
patch({
name: value.name ?? "",
email: value.email ?? "",
rate: Math.min(100, Math.max(0, Number(value.rate) || 0)),
});
}, 50, { leading: true, trailing: true }),
[patch]
);
useEffect(() => {
const sub = watch((value) => throttled(value));
return () => {
sub.unsubscribe();
throttled.cancel();
};
}, [watch, throttled]);
return null;
}
ポイントは 2 つ。
useMemoで throttle 関数の参照を安定 させる。毎レンダリングで作り直すと意味がない- cleanup で
throttled.cancel()を呼んで 保留中のコールを破棄 する
50ms は「入力体感を犠牲にしない上限」の経験則です。もっと長くすると 保存ボタン直前の同期が遅延 するのでお勧めしません。
ポイント
- まず
performance.mark/measureで 1 サイクルの所要時間を計測 する。推測しない - 1 サイクル 1ms 以下なら 何もしない。selector / useShallow / equalityFn の見直しが先
patchFormData内で 差分が無いときは早期 return する。空書き込みのノイズを減らすsubscribeWithSelectorの selector で 値を丸める と、丸めた結果が同じなら発火しない- 最後の手段が
watch側の throttle。useMemoで参照安定 + cleanup でcancel()
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- RHF 編 —
watchの基本実装 - useShallow 編 — 再描画範囲を狭める
- Store Monitor 編 — Monitor 側の最適化
- Zustand Test (Build) — 完成例と外部デモ