React.js

ReactのuseTransitionとuseDeferredValue:入力と一覧更新の優先度を分ける

この記事でわかること

useTransitionとuseDeferredValueを検索画面のコードで比較。入力と結果更新の優先度、更新中の表示、デバウンスとの違いをReact 18対応の例で学びます。

検索フォームで大量の結果を表示する場合、入力への反応と結果の描画を分けて考えることがあります。useTransitionuseDeferredValueは、更新の優先度を調整するReactのHooksです。処理を高速化する魔法ではなく、急いで反映する部分と後から追従できる部分を整理する機能として学びましょう。

2つのHooksを使い分ける

自分で更新するならuseTransition

useTransitionisPendingstartTransitionを返します。自分が実行するstate更新をTransitionとして指定し、その更新が保留中かどうかを表示できます。入力欄そのものの値は通常の更新とし、結果表示に使う値をTransitionで更新する構成が例になります。

受け取った値を遅れて使うならuseDeferredValue

useDeferredValueには現在の値を渡し、描画が後から追従できる値を受け取ります。更新処理を自分で持っていないpropsなどにも使えます。初回描画では通常、渡した値と同じ値を返します。この記事ではReact 18でも使える1引数の形式で説明します。

共通の商品一覧を準備する

Results.tsxを作成する

ViteのReact・TypeScriptプロジェクトでsrc/Results.tsxを作成します。件数は描画の変化を観察するために多めにしています。実際の大規模一覧ではページ分割や仮想化も検討してください。

import { memo } from 'react';

const products = Array.from({ length: 1200 }, (_, index) => ({
  id: `product-${index + 1}`,
  name: `商品 ${index + 1}`,
}));

export const Results = memo(function Results({ query }: { query: string }) {
  const keyword = query.trim();
  const visible = products.filter((product) => product.name.includes(keyword));

  return (
    <div>
      <p>検索結果:{visible.length}件</p>
      {visible.length === 0 ? <p>該当する商品はありません。</p> : (
        <ul>
          {visible.map((product) => <li key={product.id}>{product.name}</li>)}
        </ul>
      )}
    </div>
  );
});

memoで包むのは、親の入力値だけが変わり、一覧に渡す検索語が同じ間の再レンダーを省略しやすくするためです。詳しくはuseMemo・useCallbackの記事を参照してください。

useTransitionで結果用のstateを更新する

TransitionSearch.tsxを作成する

src/TransitionSearch.tsxを作成します。入力欄はinput、結果一覧はqueryを使います。入力欄の更新をTransitionへ入れないことがポイントです。

import { useState, useTransition } from 'react';
import { Results } from './Results';

export function TransitionSearch() {
  const [input, setInput] = useState('');
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(value: string) {
    setInput(value);
    startTransition(() => {
      setQuery(value);
    });
  }

  return (
    <section aria-labelledby="transition-heading">
      <h2 id="transition-heading">useTransitionの検索</h2>
      <label>
        商品名
        <input value={input} onChange={(event) => handleChange(event.target.value)} />
      </label>
      <p role="status">{isPending ? '結果を更新中です。' : '結果の更新が完了しました。'}</p>
      <div aria-busy={isPending} style={{ opacity: isPending ? 0.6 : 1 }}>
        <Results query={query} />
      </div>
    </section>
  );
}

startTransitionの中の処理は即座に呼ばれる

startTransitionに渡した関数そのものを後回しにするわけではありません。そこで指定したstate更新を、割り込み可能な描画として扱います。関数内に重い同期ループを置いても、そのJavaScript処理が別スレッドへ移るわけではありません。

ここでは同期的なstate更新だけを使っています。非同期Actionやawaitを含むコードへ広げる場合は、Reactのバージョンごとの仕様を確認してください。

useDeferredValueで結果用の値を受け取る

DeferredSearch.tsxを作成する

src/DeferredSearch.tsxを作成します。こちらは入力用のstateを一つだけ持ち、一覧用の値をuseDeferredValueから受け取ります。

import { useDeferredValue, useState } from 'react';
import { Results } from './Results';

export function DeferredSearch() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <section aria-labelledby="deferred-heading">
      <h2 id="deferred-heading">useDeferredValueの検索</h2>
      <label>
        商品名
        <input value={query} onChange={(event) => setQuery(event.target.value)} />
      </label>
      <p role="status">{isStale ? '前の検索結果を表示しています。' : '最新の検索結果です。'}</p>
      <div aria-busy={isStale} style={{ opacity: isStale ? 0.6 : 1 }}>
        <Results query={deferredQuery} />
      </div>
    </section>
  );
}

古い結果を表示していることを伝える

入力値と遅れて反映される値が異なる間は、前の結果が表示される可能性があります。例では文章、透明度、aria-busyで更新中であることを示します。色だけに意味を持たせないようにしています。

端末が速く更新が軽い場合、更新中の表示は目で確認できないこともあります。毎回一定時間表示されるとは限りません。

2つの例を実行して確認する

App.tsxに配置する

src/App.tsxを次の内容にします。src/main.tsxはViteのテンプレートのままで構いません。

import { TransitionSearch } from './TransitionSearch';
import { DeferredSearch } from './DeferredSearch';

export default function App() {
  return (
    <main>
      <h1>入力と結果更新の優先度</h1>
      <TransitionSearch />
      <DeferredSearch />
    </main>
  );
}

検索結果を確認する

  • それぞれ初期状態では1200件が表示されます。
  • 「商品 1200」で検索すると、結果は1件になります。
  • 存在しない語句では0件の案内になります。
  • 片方を操作しても、もう片方の検索条件は変わりません。
  • 入力を続けた後、最終的に最新の入力に対応した結果が表示されることを確認します。

性能を比較するときは、同じ端末・データ量・操作で測定します。React Developer Toolsなどで描画を確認し、この例だけで常に高速になると結論づけないでください。

よくある誤解

デバウンスや通信回数の削減とは異なる

どちらも「入力が止まって300ミリ秒後に通信する」といった固定時間の待機ではありません。useDeferredValueだけで通信回数は減りません。検索APIの呼び出しには、デバウンス、キャッシュ、古いレスポンスの扱いなどを別途設計します。

CPU処理を別スレッドに移すわけではない

大量データの同期計算自体がボトルネックなら、アルゴリズムの見直しやWeb Workerなどが候補になります。これらのHooksは、Reactの更新をどう進めるかという問題を扱います。

参考資料

基本機能と合わせて読む

まずは基本Hooks入門で通常のstate更新を理解し、必要な場合に描画の優先度を調整してください。

公式ドキュメント

y.
WRITTEN BY

y_ymo10

SEの部屋で、JavaScript・TypeScript・React.js・Next.jsの開発ノートを公開しています。

ほかの開発ノートを読む →