React, Vue.js
投稿日:

React startTransition/useDeferredValueで”固まらないUI”を実現する

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、週末のバイクツーリング中に立ち寄った道の駅で、観光地検索アプリを使っていた時のことです。検索ワードを入力するたびに画面が一瞬固まって、文字入力が追いつかない。イライラして結局使うのをやめてしまいました。帰宅後、ふと「これ、自分たちが作っているアプリでも起きてないだろうか?」と気になって、いくつかのプロジェクトを見直してみたんです。

*バイクの振動で手が震えてたのもあるかもしれませんが、それにしても反応が悪すぎました。。。

特に大規模な商品一覧やデータテーブルでライブ検索機能を実装している場合、ユーザーが文字を入力するたびに数千件のデータをフィルタリングして再レンダリングする必要があります。この処理が重いと、入力欄がカクついてユーザー体験が大きく損なわれます。今回は、React 18以降で導入されているstartTransitionとuseDeferredValueを使って、この問題を解決する方法を解説します。

従来の方法ではUIが固まる理由

React では、状態が更新されると即座に再レンダリングが走ります。これは通常問題ありませんが、レンダリングコストが高い場合、メインスレッドがブロックされて入力が遅延します。

なぜこれが問題になるのでしょうか。JavaScript はシングルスレッドで動作するため、重い処理が実行されている間は他の処理が待たされます。つまり、5000件のデータをフィルタリングしている間は、ユーザーの入力イベントも処理できません。結果として「入力したのに反応しない」という状態が発生するのです。

問題のある実装例

まず、よくある実装パターンを見てみましょう。この例では、入力のたびにすぐさま検索結果を更新しています。

function ProductSearch() {
  const [query, setQuery] = useState('');
  const [products] = useState(generateProducts(5000));

  // 入力のたびに5000件をフィルタリング
  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <>
       setQuery(e.target.value)} />
      
    
  );
}

この実装では、onChangeイベントでsetQueryを呼び出すと、すぐにfilteredの再計算が走ります。そしてProductListが即座に再レンダリングされます。この一連の処理が完了するまで、次の入力イベントは処理されません。実際に計測してみると、1文字入力するたびに平均で450msの遅延が発生していました。

優先度制御による解決

React 18からは、更新に優先度をつけることができるようになりました。これにより「ユーザーの入力はすぐに反映するが、重い計算結果の表示は後回しにする」という制御が可能になります。重要なのは、入力欄の値はすぐに更新されるため、ユーザーは「ちゃんと入力できている」と感じられる点です。

startTransition を使った実装

startTransitionは、その中の状態更新を「低優先度」としてマークする関数です。Reactはこれを見て、「この更新は緊急ではないので、他の重要な処理があればそちらを先にやる」と判断します。

import { useState, useTransition } from 'react';

function ProductSearch() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [products] = useState(generateProducts(5000));

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // 入力欄は即座に更新
    startTransition(() => {
      setDeferredQuery(value); // 検索は低優先度
    });
  };

  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(deferredQuery.toLowerCase())
  );

  return (
    <>
      
      {isPending && 検索中...}
      
    
  );
}

この実装のポイントは、queryとdeferredQueryという2つの状態を持つことです。queryは入力欄の表示に使い、deferredQueryは実際の検索処理に使います。ユーザーが「a」と入力すると、まずsetQuery(‘a’)が即座に実行されて入力欄に「a」が表示されます。次にstartTransition内のsetDeferredQuery(‘a’)が実行されますが、これは低優先度なので、他の処理があればそちらが優先されます。

useDeferredValue を使ったシンプルな実装

useDeferredValueは、値そのものを「遅延バージョン」として取得するフックです。startTransitionよりも宣言的で、状態を2つ管理する必要がありません。

import { useState, useDeferredValue, useMemo } from 'react';

function ProductSearch() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const [products] = useState(generateProducts(5000));

  const filtered = useMemo(() => {
    return products.filter(p => 
      p.name.toLowerCase().includes(deferredQuery.toLowerCase())
    );
  }, [products, deferredQuery]);

  const isPending = query !== deferredQuery;

  return (
    <>
       setQuery(e.target.value)} />
      {isPending && 検索中...}
      
    
  );
}

useDeferredValueは内部的にstartTransitionと同様の仕組みを使っており、渡された値の「遅延バージョン」を返します。queryが更新されてもすぐにはdeferredQueryに反映されず、Reactが「今は余裕がある」と判断したタイミングで更新されます。また、useMemoでフィルタリング結果をメモ化することで、deferredQueryが変わらない限り再計算されないようにしています。

こちらにまとめて試せるコードをおいてみました。50,000件のランダムなレコードを扱った場合の体感速度の違いが分かると思います(MacProレベルのパソコンだと、この程度では何も変わらないかもしれませんが。。。)

パフォーマンス計測結果

指標従来適用後改善率
入力遅延450ms45ms90%
INPスコア820ms180ms78%

実装時の注意点

処理が非常に速い場合、ローディング表示が一瞬だけ表示されて逆に気になることがあります。一定時間以下の場合は表示しないようにすると、より自然な体験になります。

const [showLoading, setShowLoading] = useState(false);

useEffect(() => {
  if (isPending) {
    const timer = setTimeout(() => setShowLoading(true), 200);
    return () => clearTimeout(timer);
  } else {
    setShowLoading(false);
  }
}, [isPending]);

まとめ

今回紹介したstartTransitionとuseDeferredValueは、React 18で追加された優先度制御の仕組みです。大規模なデータを扱うライブ検索やフィルタリング機能では、入力の反応速度がユーザー体験に直結します。従来の実装では、すべての更新が同じ優先度で処理されるため、重い計算が入力をブロックしてしまいます。

この問題を解決するために押さえておきたいポイントは以下の3つです。

  • 入力は即座に反映し、結果表示は遅延させる優先度制御を導入する
  • isPendingフラグでローディング状態を表示し、ユーザーに処理中であることを伝える
  • useMemoやReact.memoと組み合わせて、不要な再計算を防ぐ

実際のプロジェクトでは、入力遅延が450msから45msに改善され、INPスコアも78%向上しました。次のプロジェクトでぜひ試してみてください。

株式会社ファストコーディングでは、こういったReactのパフォーマンス最適化について豊富な経験があります。INPスコアの改善でお困りの際は、お問い合わせフォームからご相談ください