© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsperformance

Zustand Test の watch 同期コストを計測して throttle で抑える

RHF の watch から settingsStore に毎キーストローク同期する負荷を performance.mark で計測し、必要なら throttle / 差分検知で抑える手順を解説する。

難易度: 中級·更新: 2026-05-07·
react-hook-formzustand

対応バージョン

nextjs 15react 19zustand 5react-hook-form 7

前提環境

RHF の watch と Zustand の selector を理解していること

概要

Zustand Test は 1 文字入力するたびに patchFormData → storeSync → 各 Monitor の再描画 が走ります。フィールド数や Monitor の数が増えると、入力中の体感が悪化することがあります。

ここで重要なのは 「まず計測する」 ことです。selector の絞り込み・useShallow・throttle のいずれを足すかは、計測した結果で判断します。

手段いつ使う
useShallow / 個別 selectorMonitor の再描画範囲が広いとき
subscribeWithSelector の equalityFnstoreSync 側で発火しすぎているとき
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) — 完成例と外部デモ

注意点

Playground 追加記事。1 文字ごとの同期がどれくらいコストか・抑える必要があるかを判断する材料を集めるためのレシピ。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test の useShallow と primitive selector を使い分ける

    オブジェクト selector で useShallow を使う場合と、複数の primitive selector に分ける場合の判断基準を、再レンダリング挙動と一緒に解説する。

  • Zustand Test の React Hook Form を watch・handleSubmit・formState で読み解く

    useForm の watch でリアルタイム同期し、handleSubmit で確定値を扱い、formState で UI を切り替える Playground の RHF 実装を1ステップずつ解説する。

  • Zustand の selector で不要な再レンダリングを防ぐ

    useStore にセレクタ関数を渡し、参照する state を絞ることで不要な再レンダリングを防ぐ実装例。

  • Zustand Test の Form / Monitor / TabBar を Storybook で分離する

    Zustand に依存する UI を Storybook で取り扱うために、store のリセット / decorator / play function を組み合わせて状態をストーリー単位で固定する手順を解説する。

  • Zustand Test の型を Zod スキーマから 4 ストアまで貫く

    Zod スキーマ → z.infer → useForm → settings / profile / detail / ui の 4 ストアまで、Playground の TypeScript の流れを1ステップずつ追う。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る