Article

React 19のuseTransitionで実装する快適な検索体験

高田晃太郎
React 19のuseTransitionで実装する快適な検索体験

GoogleのCore Web Vitalsでは、INPの良好閾値は200ms以下と定義されています¹。INP(Interaction to Next Paint)は「ユーザー操作に対して次のペイントが起きるまでの時間」を代表値で捉える指標で、実利用データの大きいほう寄り(たとえばp75やp95)で評価されます。人間の知覚の研究では、100ms前後までが“瞬時”と感じられる境界²で、これを超えるとタイピング中の引っかかりとして識別されやすくなります。検索UIはまさに入力と描画が綱引きする場であり、フィルタリングやランキングの計算、巨大なリストの再描画がブラウザのメインスレッド上で競合すると、入力の応答性が目に見えて落ちます。React 19が提供するuseTransitionは、こうした競合をアプリケーション側で制御し、重要度の異なる更新を分離するための標準的な道具になりました³⁴。

一般的な再現デモでも、同期的な再描画と比べて、入力のp95遅延が数百msから約200ms前後まで縮む、INPが数十%改善する、といった傾向が確認されることがあります(web.devのINP定義とアトリビューションの考え方に準拠した計測)⁵。もちろんデータ量や端末性能に強く依存しますが、打鍵の軽さを保ったまま重い結果描画を後回しにするという設計方針は、多くの現場で再現しやすい改善をもたらします。本稿では、React 19のuseTransitionを中核に、検索体験を滑らかにする実装、INPの具体的な計測手順、そしてビジネス指標への波及までを整理します。

体験速度を支配するのは「更新の重要度」を分ける設計

検索UIにおける遅さの正体は単一ではありません。入力イベント処理、ネットワーク往復、サーバサイドの集計、クライアントでのソートやハイライト、仮想化されていないリストの再描画などが積み重なって体感性能を悪化させます。ブラウザは通常、これらをメインスレッド上で順番に処理します。Reactの並行レンダリングは、この重なりを単に速くするのではなく、ユーザーがいま求めている操作、すなわち入力の継続を最優先に扱えるようにします⁴。useTransitionは、更新を**「緊急(urgent)」と「遷移(transition)」に分け、緊急の入力は即座に反映し、コストの高い描画や状態の切り替えは遷移に回して中断可能(interruptible)**にします⁶⁷。ユーザーは文字を打つたびに手応えを得られ、結果は一拍遅れても前の表示を保ったまま新しい結果が整い次第差し替えられます⁸。この「前のUIを保ちながら次を準備する」挙動は、検索のように連続する更新で真価を発揮します。

重要なのは、useTransitionがネットワークを速くするわけではないという点です。遷移中にスピナーやプログレスで期待値を調整しつつ、前の結果を維持することで心理的な待ち時間を短縮し、INPのような入力応答の指標を直接的に改善できます³。結果として、ファネルの上流である検索体験のストレスが減れば、下流の詳細閲覧やカート投入までの到達率が上がることが期待できます。

useTransitionで「打鍵は軽く、結果は中断可能」にする実装

最初に、遷移を使っていないベースラインを示します。入力と結果描画が同一の緊急更新として扱われるため、重い結果レンダリングが入力を阻害します。

import React, { useEffect, useState, ChangeEvent } from 'react';

type Item = { id: string; title: string };

async function fetchItems(query: string, signal?: AbortSignal): Promise<Item[]> {
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.items as Item[];
}

export function SearchNoTransition() {
  const [input, setInput] = useState('');
  const [items, setItems] = useState<Item[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let abort = new AbortController();
    if (input.trim() === '') { setItems([]); return; }
    setLoading(true); setError(null);
    fetchItems(input, abort.signal)
      .then(setItems)
      .catch(e => { if ((e as any).name !== 'AbortError') setError(e as Error); })
      .finally(() => setLoading(false));
    return () => abort.abort();
  }, [input]);

  const onChange = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);

  return (
    <div>
      <input value={input} onChange={onChange} placeholder="Search" />
      {loading && <span>Loading...</span>}
      {error && <span role="alert">{error.message}</span>}
      <ul>{items.map(i => <li key={i.id}>{i.title}</li>)}</ul>
    </div>
  );
}

React 19でuseTransitionを導入すると、入力自体は緊急更新として即時に反映しつつ、クエリの反映と結果の更新を遷移に回せます。これにより、重いリストの描画は中断可能になり、タイプ中の引っかかりが解消されます。

import React, { useEffect, useState, useTransition, ChangeEvent } from 'react';

type Item = { id: string; title: string };

async function fetchItems(query: string, signal?: AbortSignal): Promise<Item[]> {
  const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  return data.items as Item[];
}

export function SearchWithTransition() {
  const [input, setInput] = useState('');          // 緊急更新(タイピング)
  const [query, setQuery] = useState('');          // 遷移で反映するクエリ
  const [items, setItems] = useState<Item[]>([]);
  const [error, setError] = useState<Error | null>(null);
  const [isPending, startTransition] = useTransition();

  useEffect(() => {
    let abort = new AbortController();
    if (query.trim() === '') { setItems([]); return; }
    setError(null);
    fetchItems(query, abort.signal)
      .then(next => setItems(next))
      .catch(e => { if ((e as any).name !== 'AbortError') setError(e as Error); });
    return () => abort.abort();
  }, [query]);

  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    setInput(next);                         // 即時反映(緊急)
    startTransition(() => setQuery(next));  // 中断可能な遷移
  };

  return (
    <div>
      <input value={input} onChange={onChange} placeholder="Search" />
      {isPending && <span aria-live="polite">Updating...</span>}
      {error && <span role="alert">{error.message}</span>}
      <Results items={items} preserveCount={isPending ? 20 : 100} />
    </div>
  );
}

function Results({ items, preserveCount }: { items: Item[]; preserveCount: number }) {
  // 疑似的に重い描画を再現
  const visible = items.slice(0, preserveCount);
  return <ul>{visible.map(i => <li key={i.id}>{i.title}</li>)}</ul>;
}

上記のように、入力は常に軽く、結果は遷移として差し替えられます。通信レイヤーでは常に最新のクエリだけを有効にする必要があります。AbortControllerを併用し、古いリクエストの結果でUIが巻き戻らないようにします。フックに切り出しておくと、検索面の再利用性と堅牢性が上がります。

import { useEffect, useRef, useState } from 'react';

type Item = { id: string; title: string };

type Status = 'idle' | 'loading' | 'success' | 'error';

export function useSearch(query: string) {
  const [status, setStatus] = useState<Status>('idle');
  const [items, setItems] = useState<Item[]>([]);
  const [error, setError] = useState<Error | null>(null);
  const lastQuery = useRef('');

  useEffect(() => {
    if (!query.trim()) { setItems([]); setStatus('idle'); return; }
    const controller = new AbortController();
    const run = async () => {
      try {
        setStatus('loading'); setError(null); lastQuery.current = query;
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = (await res.json()) as { items: Item[] };
        if (controller.signal.aborted || query !== lastQuery.current) return;
        setItems(data.items); setStatus('success');
      } catch (e: unknown) {
        if ((e as any)?.name === 'AbortError') return;
        setError(e as Error); setStatus('error');
      }
    };
    run();
    return () => controller.abort();
  }, [query]);

  return { status, items, error } as const;
}

このフックをuseTransitionのクエリと組み合わせると、UIは常に最新状態に追従し、古いレスポンスは破棄されます。さらに、エラーを境界で扱うと、障害時の復旧動線が明確になります。

import React from 'react';

export class ErrorBoundary extends React.Component<{ fallback: React.ReactNode }, { hasError: boolean }> {
  constructor(props: any) { super(props); this.state = { hasError: false }; }
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error: unknown) { console.error('Search error', error); }
  render() { return this.state.hasError ? this.props.fallback : this.props.children; }
}

フィルタリングやハイライトのようなクライアント側の重い派生計算にはuseDeferredValueが適切です。useDeferredValueは値の伝播を遅延させ、入力は即時反映、重い派生は一呼吸置いて反映という構図を実現します。ネットワークを伴う状態反映や、大規模なコンポーネントツリーの差し替えにはuseTransitionがより明確です⁹。

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

type Item = { id: string; title: string };

export function LocalFilter() {
  const [input, setInput] = useState('');
  const deferred = useDeferredValue(input); // 派生計算だけを遅らせる
  const items = useMemo(() => heavyLocalSearch(deferred), [deferred]);
  const onChange = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);
  return (
    <div>
      <input value={input} onChange={onChange} placeholder="Type to filter" />
      <ul>{items.map(i => <li key={i.id}>{i.title}</li>)}</ul>
    </div>
  );
}

function heavyLocalSearch(q: string): Item[] {
  // CPU負荷の高いフィルタを模擬
  const data = (window as any).__DATA__ as Item[];
  const normalized = q.toLowerCase();
  const start = performance.now();
  while (performance.now() - start < 8) { /* busy work 8ms */ }
  return data.filter(i => i.title.toLowerCase().includes(normalized));
}

ここまでの実装により、入力は緊急更新として常に軽く、結果は中断可能な遷移として扱われ、古いレスポンスは破棄されます。検索ソートやローカルフィルタはuseDeferredValueを用いて派生計算の遅延伝播に委ねることで、全体の体験速度を底上げできます。

計測とSLA:INP 200ms以下を継続的に維持する

設計は計測によって裏付けられて初めてビジネスへ貢献します。Core Web VitalsのINPは、ユーザー操作に対する最悪に近い反応時間を代表値として計算します⁵。良好は200ms以下、要改善は200〜500ms、低品質は500ms超が一般的な目安です¹。開発環境でも、入力イベントからペイント完了までの経路を可視化しておくと、遷移の効果や退行を早期に察知できます。以下では、フィールド計測(本番の実ユーザー)とラボ計測(開発・検証)それぞれの具体的な手順を整理します。

まず、フィールド計測としてweb-vitalsのアトリビューションを使い、INPと原因の手がかり(どのイベントか、どの要素か)を収集します。metric.valueはms単位です。

import { onINP } from 'web-vitals/attribution';

type Sample = {
  value: number;             // INP値(ms)
  eventType: string;         // 'click' | 'keydown' など
  target: string;            // CSSセレクタ風表現
  navigationType: string;    // 'navigate' など
};
const samples: Sample[] = [];

onINP(metric => {
  const detail = metric.attribution;
  samples.push({
    value: metric.value,
    eventType: detail.eventType,
    target: detail.eventTarget,
    navigationType: detail.navigationType,
  });
  // 実運用ではバッチ送信(サンプリング率も設定)
  // navigator.sendBeacon('/rum', JSON.stringify({ metric: 'INP', ...samples[samples.length - 1] }));
  console.log('INP', samples[samples.length - 1]);
});

収集した配列からp75やp95を出す際は、単純なソートで十分です。ルートやデバイス種別ごとに集計すると、ボトルネックの位置が見えやすくなります。

function percentile(values: number[], p: number) {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1)));
  return sorted[idx];
}

// 例: const p95 = percentile(samples.map(s => s.value), 95);

次に、ラボ計測としてアプリ内の入力からコミットまでの時間を測り、遷移の有無による差分を確認します。以下のスニペットは、入力ハンドラの先頭でタイムスタンプを取り、次のペイント完了後の時点で差分を記録します。p95が200msを大きく超えるケースが散見されるなら、遷移で分離できていない重い処理が混入している可能性が高いと判断できます¹。

let t0 = 0;
export function markInputStart() { t0 = performance.now(); }
export function markCommit(label: string) {
  requestAnimationFrame(() => {
    const dt = performance.now() - t0;
    console.log(`[${label}] input-to-paint: ${Math.round(dt)}ms`);
    // navigator.sendBeacon('/rum', JSON.stringify({ label, dt }));
  });
}

これらの計測フックは、前述のSearchWithTransitionに容易に組み込めます。たとえばonChangeの先頭でmarkInputStart()を呼び、itemsが更新されたタイミングでmarkCommit(‘search results’)を呼べば、導入前後での差分を追えます。Chrome DevToolsのPerformanceパネルで「Web Vitals」レーンを併用すると、長いタスクやレンダリング待ちが視覚的に確認できます。フィールドでは、サンプル率(例: 1〜5%)とユーザー匿名化に配慮し、ルーティング名・デバイス・ネットワーク状態と合わせてダッシュボード化すると、SLO監視が運用に乗ります。

計測の結果、INPのp95が200ms以下に入り、入力からペイントまでの平均が100ms台に収まると、主観評価でも軽さが安定して感じられます¹²。再現デモの範囲でも、仮想化無しで大量リストを描画する厳しめの条件下で、useTransitionにより入力遅延の悪化を抑え、平均・p95ともに数十%の短縮が観測されるケースがあります(計測方法はINPの定義・アトリビューションに準拠)⁵。条件依存ではあるものの、導入判断の材料として有用な差です。

ビジネス観点では、検索体験の改善は回遊とコンバージョンに直結します。UIの遷移待ちで離脱が生じると、広告費や集客の投資効率が悪化します。開発コストは既存の検索コンポーネントにuseTransition、AbortController、最低限のメトリクス送信を加える程度で、現実的なスプリントに収まります。導入の見込み効果として、INPの改善と同時にCS対応の「反応が遅い」といった問い合わせの件数が減る傾向が期待でき、結果として機能開発に割ける時間が増えます。運用フェーズでは、SLOとしてINP p95≤200ms、入力からペイントまでの平均≤120ms、エラー率≤1%といった実用的な目標を定義すると、改善サイクルが回しやすくなります¹²。

まとめ:体験の主導権をアプリ側に取り戻す

検索はユーザー意図を受け止める最初の接点です。ネットワークやデータ量に左右されやすい領域だからこそ、アプリケーション側で更新の重要度を整理し、入力の軽さを最優先に扱う設計が意味を持ちます。React 19のuseTransitionは、そのための標準的な手段を提供し、前のUIを保ちながら新しい結果を準備するという自然な体験を実現します⁸。エラーハンドリングと計測を組み合わせれば、SLAに裏打ちされた改善を継続できます。

次のリリースサイクルで、検索コンポーネントの入力と結果更新を分離し、INPのp95を200ms以下に収めることを一つのマイルストーンにしてみてはいかがでしょうか。 実装の全体像は本稿のコードで揃っています。

参考文献

  1. web.dev — Interaction to Next Paint (INP): To ensure you’re delivering user… good threshold is 200 ms across devices. https://web.dev/inp/#:~:text=To%20ensure%20you%27re%20delivering%20user,across%20mobile%20and%20desktop%20devices
  2. PubNub (dev.to) — How fast is real-time? Human perception and technology; Robert B. Miller (1968) “Response time” and the ~100 ms boundary. https://dev.to/pubnub/how-fast-is-real-time-human-perception-and-technology-1308#:~:text=In%201968%20Robert%20Miller%20published,magnitude%20of%20computer%20mainframe%20responsiveness
  3. React Docs — useTransition API reference. https://react.dev/reference/react/useTransition#:~:text=The%20function%20passed%20to%20,the%20first%20update%20to%20finish
  4. React v18 Blog — A key property of Concurrent React. https://legacy.reactjs.org/blog/2022/03/29/react-v18.html#:~:text=A%20key%20property%20of%20Concurrent,see%20the%20result%20on%20screen
  5. web.dev — INP deep dive and attribution note. https://web.dev/inp/#:~:text=Note%3A%20Interaction%20to%20Next%20Paint,the%20vast%20majority%E2%80%94of%20user%20interactions
  6. React v18 Blog — A transition is a new concept: non-urgent updates. https://legacy.reactjs.org/blog/2022/03/29/react-v18.html#:~:text=A%20transition%20is%20a%20new,urgent%20updates
  7. React v18 Blog — Updates wrapped in startTransition are interruptible. https://legacy.reactjs.org/blog/2022/03/29/react-v18.html#:~:text=Updates%20wrapped%20in%20startTransition%20are,render%20only%20the%20latest%20update
  8. React v18 Blog — Transitions opt in to concurrent rendering and keep previous UI. https://legacy.reactjs.org/blog/2022/03/29/react-v18.html#:~:text=Transitions%20will%20opt%20in%20to,the%20transition%20content%20in%20the
  9. React Docs — useDeferredValue API reference. https://react.dev/reference/react/useDeferredValue#:~:text=You%20can%20also%20apply%20,the%20rest%20of%20the%20UI