T-CREATOR

Zustand × useTransition 概説:並列レンダリング時代に安全な更新を設計する

Zustand × useTransition 概説:並列レンダリング時代に安全な更新を設計する

React の状態管理ライブラリである Zustand と、React 18 で導入された useTransition フックの組み合わせは、並列レンダリング時代において非常に強力な武器となります。 この記事では、両者を組み合わせることで、どのようにユーザー体験を向上させ、安全な状態更新を実現できるのかを詳しく解説していきますね。

背景

React 18 の並列レンダリング

React 18 では、Concurrent Features(並列機能)が正式に導入されました。 これにより、React は複数のレンダリング作業を同時に処理し、優先度の高い更新を先に反映できるようになったんです。

以下の図は、従来の同期レンダリングと並列レンダリングの違いを示しています。

mermaidflowchart LR
  A["ユーザー操作"] --> B["状態更新"]
  B --> C["レンダリング開始"]
  C --> D{"並列レンダリング<br/>有効?"}
  D -->|従来| E["同期処理<br/>UI ブロック"]
  D -->|React 18| F["並列処理<br/>UI 応答"]
  E --> G["完了まで待機"]
  F --> H["優先度管理"]
  G --> I["画面更新"]
  H --> I

図の要点

  • 従来の同期処理では、重い処理が UI をブロックしていました
  • React 18 では優先度管理により、UI の応答性を保ちながら処理できます
  • ユーザー操作への即座のフィードバックが可能になりました

Zustand の特徴

Zustand は、シンプルで軽量な状態管理ライブラリです。 Redux のような複雑な設定が不要で、hooks ベースの直感的な API を提供してくれます。

Zustand の主な特徴は以下の通りです。

#特徴説明
1ミニマルな API学習コストが低く、すぐに使い始められます
2TypeScript サポート型安全性が高く、開発体験が向上します
3ミドルウェア対応永続化やログ出力などの機能を簡単に追加できます
4React 外でも利用可能バニラ JavaScript でも状態を管理できます
5軽量バンドルサイズが小さく、パフォーマンスに優れています

useTransition フックの役割

useTransition は、状態更新を「緊急」と「非緊急」に分類するための React 18 の新機能です。 非緊急な更新をトランジション(遷移)としてマークすることで、UI の応答性を損なわずに重い処理を実行できるんですね。

mermaidsequenceDiagram
  participant User as ユーザー
  participant UI as UI コンポーネント
  participant Trans as useTransition
  participant State as 状態管理

  User->>UI: 入力操作
  UI->>Trans: startTransition()
  Trans->>State: 非緊急更新
  UI-->>User: 即座にフィードバック
  State->>State: バックグラウンド処理
  State->>UI: 更新完了
  UI->>User: 最終結果表示

図で理解できる要点

  • ユーザー操作に対して即座にフィードバックを返せます
  • 重い処理はバックグラウンドで実行されます
  • UI の応答性とデータ処理の両立が可能になります

課題

従来の状態管理における問題点

React 18 以前の状態管理では、いくつかの課題がありました。 特に大規模なアプリケーションでは、これらの課題が顕著になってきます。

UI のブロッキング問題

重い処理を伴う状態更新が発生すると、UI 全体がフリーズしてしまうことがありました。 例えば、大量のデータをフィルタリングする際に、入力フィールドの反応が遅くなる現象です。

typescript// 問題のあるコード例:重い処理が UI をブロック
import { useState } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // この関数が重い処理を含む場合、UI がフリーズします
  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value); // 入力の更新も遅延

    // 重い計算処理
    const filtered = heavyFilterOperation(
      largeDataset,
      value
    );
    setResults(filtered);
  };

  return <input value={query} onChange={handleSearch} />;
}

上記のコードでは、heavyFilterOperation という重い処理が実行されている間、入力フィールドの更新も遅延してしまいます。 ユーザーはタイピングしても画面に反映されない、というストレスフルな体験をすることになるんですね。

状態更新の優先度管理の欠如

すべての状態更新が同じ優先度で処理されるため、緊急性の高い UI 更新が後回しにされることがありました。

mermaidflowchart TD
  A["複数の状態更新"] --> B["更新キュー"]
  B --> C["順次処理"]
  C --> D["緊急更新も<br/>待機が必要"]
  D --> E["UI 応答性低下"]

  style D fill:#ffcccc
  style E fill:#ffcccc

図の要点

  • 従来は、すべての更新が同じキューで順次処理されていました
  • 緊急性の高い UI 更新も待たされることがありました
  • ユーザー体験が低下する原因となっていました

Zustand 単体での制約

Zustand は優れた状態管理ライブラリですが、単体では並列レンダリングの恩恵を最大限に活かせません。

更新の粒度制御の困難さ

Zustand の set 関数で状態を更新すると、その更新は即座に反映されます。 しかし、これでは重い処理を含む更新と、即座に反映したい UI 更新を区別できないんです。

typescript// Zustand の基本的な使用例
import { create } from 'zustand';

interface SearchStore {
  query: string;
  results: string[];
  setQuery: (query: string) => void;
  performSearch: (query: string) => void;
}

const useSearchStore = create<SearchStore>((set) => ({
  query: '',
  results: [],

  // 即座に反映される更新
  setQuery: (query) => set({ query }),

  // 重い処理を含む更新も同様に即座に実行
  performSearch: (query) => {
    const results = heavyFilterOperation(
      largeDataset,
      query
    );
    set({ results }); // UI をブロックする可能性
  },
}));

上記のコードでは、setQueryperformSearch の両方が同じ優先度で処理されてしまいます。 本来なら、setQuery は即座に反映し、performSearch は後回しにしたいところですね。

レンダリング最適化の限界

Zustand はセレクター機能で不要な再レンダリングを防げますが、重い計算処理自体を遅延させることはできません。

typescript// セレクターを使った最適化例
function SearchInput() {
  // query の変更のみを監視
  const query = useSearchStore((state) => state.query);
  const setQuery = useSearchStore(
    (state) => state.setQuery
  );

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

function SearchResults() {
  // results の変更のみを監視
  const results = useSearchStore((state) => state.results);

  // しかし、results の計算自体が重い場合は依然として問題
  return <div>{results.map(/* ... */)}</div>;
}

セレクターで再レンダリングは最適化できますが、results の計算処理が重い場合、その影響は避けられません。

以下の表は、従来の課題をまとめたものです。

#課題影響発生条件
1UI ブロッキング入力遅延、フリーズ重い計算処理を含む状態更新
2優先度管理の欠如応答性の低下複数の状態更新が同時発生
3更新の粒度制御困難最適化の限界緊急・非緊急の混在
4レンダリング最適化不足パフォーマンス低下大量データの処理

解決策

Zustand と useTransition の統合パターン

Zustand と useTransition を組み合わせることで、状態更新の優先度を適切に管理できるようになります。 この統合により、UI の応答性を保ちながら、重い処理を安全に実行できるんですね。

以下の図は、統合パターンの全体像を示しています。

mermaidflowchart TB
  A["ユーザー操作"] --> B{"更新の種類"}
  B -->|緊急| C["直接 Zustand 更新"]
  B -->|非緊急| D["useTransition でラップ"]

  C --> E["即座に UI 反映"]
  D --> F["startTransition()"]
  F --> G["Zustand 更新"]
  G --> H["バックグラウンド処理"]
  H --> I["完了後に UI 反映"]

  E --> J["ユーザー体験向上"]
  I --> J

  style C fill:#ccffcc
  style D fill:#ccccff

図で理解できる要点

  • 緊急な更新は直接 Zustand で処理し、即座に反映します
  • 非緊急な更新は useTransition でラップし、バックグラウンド処理します
  • 両者を使い分けることで、最適なユーザー体験を実現できます

基本的な統合パターン

まずは、Zustand ストアの定義から見ていきましょう。

typescript// Zustand ストアの型定義
import { create } from 'zustand';

interface SearchStore {
  // 状態
  query: string;
  results: string[];
  isPending: boolean;

  // アクション
  setQuery: (query: string) => void;
  setResults: (results: string[]) => void;
  setPending: (isPending: boolean) => void;
}

上記では、検索機能に必要な状態と、それを更新するアクションの型を定義しています。 isPending は、トランジション中かどうかを示すフラグですね。

次に、ストアの実装を行います。

typescript// Zustand ストアの実装
const useSearchStore = create<SearchStore>((set) => ({
  // 初期状態
  query: '',
  results: [],
  isPending: false,

  // クエリを即座に更新(緊急な更新)
  setQuery: (query) => set({ query }),

  // 検索結果を更新(非緊急な更新)
  setResults: (results) => set({ results }),

  // ペンディング状態を更新
  setPending: (isPending) => set({ isPending }),
}));

ストアの実装では、各アクションが状態を更新するシンプルな関数として定義されています。 この段階では、まだ優先度の区別はありません。

コンポーネントでの使用パターン

次に、コンポーネント側で useTransition を組み合わせて使用します。

typescript// React コンポーネントでの import
import { useTransition } from 'react';
import { useSearchStore } from './store';

import 文では、React の useTransition と、先ほど定義した Zustand ストアをインポートしています。

typescript// コンポーネントの基本構造
function SearchComponent() {
  // useTransition の初期化
  const [isPending, startTransition] = useTransition();

  // Zustand ストアから必要な値を取得
  const query = useSearchStore((state) => state.query);
  const results = useSearchStore((state) => state.results);
  const setQuery = useSearchStore(
    (state) => state.setQuery
  );
  const setResults = useSearchStore(
    (state) => state.setResults
  );

  // 続きは次のコードブロックで
}

ここでは、useTransition フックと Zustand のセレクターを使って、必要な状態とアクションを取得しています。 isPending は React が管理するトランジション状態で、Zustand の状態とは別物です。

typescript// イベントハンドラーの実装
function SearchComponent() {
  // ...前のコードの続き

  const handleSearch = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const value = e.target.value;

    // 緊急な更新:入力値を即座に反映
    setQuery(value);

    // 非緊急な更新:重い検索処理をトランジションでラップ
    startTransition(() => {
      const filtered = heavyFilterOperation(
        largeDataset,
        value
      );
      setResults(filtered);
    });
  };

  // 続きは次のコードブロックで
}

handleSearch 関数では、2 段階の更新を行っています。 まず setQuery で入力値を即座に反映し、その後 startTransition で重い検索処理を実行するんですね。

typescript// JSX のレンダリング部分
function SearchComponent() {
  // ...前のコードの続き

  return (
    <div>
      <input
        type='text'
        value={query}
        onChange={handleSearch}
        placeholder='検索...'
      />

      {isPending && <div>検索中...</div>}

      <div>
        {results.map((result, index) => (
          <div key={index}>{result}</div>
        ))}
      </div>
    </div>
  );
}

JSX では、isPending を使ってローディング表示を出し分けています。 ユーザーは入力に対する即座のフィードバックと、検索処理の進行状況の両方を確認できるわけです。

ミドルウェアとの組み合わせ

Zustand のミドルウェア機能を活用すると、さらに高度な最適化が可能になります。

immer ミドルウェアとの連携

immer ミドルウェアを使うと、イミュータブルな状態更新をより直感的に記述できます。

typescript// immer ミドルウェアの import
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

まずは必要なパッケージをインポートします。 immer ミドルウェアは Zustand に標準で含まれていますよ。

typescript// immer を使ったストアの定義
interface Task {
  id: string;
  title: string;
  completed: boolean;
}

interface TaskStore {
  tasks: Task[];
  addTask: (task: Task) => void;
  toggleTask: (id: string) => void;
  updateTaskTitle: (id: string, title: string) => void;
}

タスク管理アプリケーションを例に、型定義を行います。 複数のタスクを管理し、追加・トグル・更新ができる仕様ですね。

typescript// immer ミドルウェアを適用したストア実装
const useTaskStore = create<TaskStore>()(
  immer((set) => ({
    tasks: [],

    // タスクを追加
    addTask: (task) =>
      set((state) => {
        state.tasks.push(task);
      }),

    // タスクの完了状態をトグル
    toggleTask: (id) =>
      set((state) => {
        const task = state.tasks.find((t) => t.id === id);
        if (task) {
          task.completed = !task.completed;
        }
      }),

    // タスクのタイトルを更新
    updateTaskTitle: (id, title) =>
      set((state) => {
        const task = state.tasks.find((t) => t.id === id);
        if (task) {
          task.title = title;
        }
      }),
  }))
);

immer を使うと、state.tasks.push()task.completed = !task.completed といった、ミュータブルな書き方ができます。 内部的には immer がイミュータブルな更新に変換してくれるので、安全性が保たれるんですね。

persist ミドルウェアとの連携

persist ミドルウェアを使うと、状態を localStorage などに永続化できます。

typescript// persist ミドルウェアの import
import { create } from 'zustand';
import {
  persist,
  createJSONStorage,
} from 'zustand/middleware';

persist ミドルウェアも Zustand に標準で含まれています。 createJSONStorage は、保存先のストレージを指定するためのヘルパー関数です。

typescript// persist を使ったストアの定義
interface UserPreferences {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  notifications: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: 'ja' | 'en') => void;
  toggleNotifications: () => void;
}

ユーザー設定を管理するストアの型を定義します。 テーマ、言語、通知の設定を保持する仕様ですね。

typescript// persist ミドルウェアを適用したストア実装
const usePreferencesStore = create<UserPreferences>()(
  persist(
    (set) => ({
      // デフォルト値
      theme: 'light',
      language: 'ja',
      notifications: true,

      // テーマを変更
      setTheme: (theme) => set({ theme }),

      // 言語を変更
      setLanguage: (language) => set({ language }),

      // 通知をトグル
      toggleNotifications: () =>
        set((state) => ({
          notifications: !state.notifications,
        })),
    }),
    {
      name: 'user-preferences', // localStorage のキー名
      storage: createJSONStorage(() => localStorage),
    }
  )
);

persist ミドルウェアを適用すると、状態の変更が自動的に localStorage に保存されます。 ページをリロードしても、以前の設定が復元されるので、ユーザー体験が向上しますね。

typescript// useTransition と persist の組み合わせ
function SettingsComponent() {
  const [isPending, startTransition] = useTransition();

  const theme = usePreferencesStore((state) => state.theme);
  const setTheme = usePreferencesStore(
    (state) => state.setTheme
  );

  const handleThemeChange = (
    newTheme: 'light' | 'dark'
  ) => {
    // テーマの適用は重い処理の可能性があるため、トランジションでラップ
    startTransition(() => {
      setTheme(newTheme);
      // グローバルな CSS 変数の更新など、重い処理
      applyThemeGlobally(newTheme);
    });
  };

  return (
    <button
      onClick={() =>
        handleThemeChange(
          theme === 'light' ? 'dark' : 'light'
        )
      }
      disabled={isPending}
    >
      {isPending
        ? '適用中...'
        : `${
            theme === 'light' ? 'ダーク' : 'ライト'
          }モードに切替`}
    </button>
  );
}

useTransition と persist を組み合わせることで、重いテーマ適用処理を非同期化しながら、設定の永続化も実現できます。 ボタンが無効化されている間も、他の UI は操作可能な状態を保てるんですね。

エラーハンドリングとローディング状態の管理

並列レンダリング時代では、エラーハンドリングとローディング状態の管理がより重要になります。

エラー状態の管理パターン

エラーを適切に管理するため、ストアにエラー状態を追加します。

typescript// エラー状態を含むストアの型定義
interface DataStore {
  data: any[];
  isLoading: boolean;
  error: Error | null;
  fetchData: () => Promise<void>;
  clearError: () => void;
}

error フィールドでエラーオブジェクトを保持し、clearError でエラーをクリアできるようにしています。

typescript// エラーハンドリングを含むストア実装
const useDataStore = create<DataStore>((set) => ({
  data: [],
  isLoading: false,
  error: null,

  fetchData: async () => {
    // ローディング開始、エラークリア
    set({ isLoading: true, error: null });

    try {
      const response = await fetch('/api/data');

      if (!response.ok) {
        throw new Error(
          `Error ${response.status}: ${response.statusText}`
        );
      }

      const data = await response.json();
      set({ data, isLoading: false });
    } catch (error) {
      // エラーをストアに保存
      set({
        error:
          error instanceof Error
            ? error
            : new Error('Unknown error'),
        isLoading: false,
      });
    }
  },

  clearError: () => set({ error: null }),
}));

fetchData 関数では、try-catch でエラーをキャッチし、ストアに保存しています。 HTTP エラーコードも含めたエラーメッセージを生成することで、デバッグがしやすくなりますね。

typescript// エラー表示コンポーネント
function DataComponent() {
  const [isPending, startTransition] = useTransition();

  const data = useDataStore((state) => state.data);
  const isLoading = useDataStore(
    (state) => state.isLoading
  );
  const error = useDataStore((state) => state.error);
  const fetchData = useDataStore(
    (state) => state.fetchData
  );
  const clearError = useDataStore(
    (state) => state.clearError
  );

  const handleFetch = () => {
    startTransition(() => {
      fetchData();
    });
  };

  // 続きは次のコードブロックで
}

コンポーネントでは、エラー状態とローディング状態の両方を監視します。 useTransition でラップすることで、データ取得中も UI の応答性を保てますよ。

typescript// エラー表示と再試行の実装
function DataComponent() {
  // ...前のコードの続き

  if (error) {
    return (
      <div role='alert'>
        <h3>エラーが発生しました</h3>
        <p>エラーコード: {error.message}</p>
        <button
          onClick={() => {
            clearError();
            handleFetch();
          }}
        >
          再試行
        </button>
      </div>
    );
  }

  return (
    <div>
      <button
        onClick={handleFetch}
        disabled={isLoading || isPending}
      >
        {isLoading || isPending
          ? '読み込み中...'
          : 'データを取得'}
      </button>

      <div>
        {data.map((item, index) => (
          <div key={index}>{item}</div>
        ))}
      </div>
    </div>
  );
}

エラー発生時には、エラーメッセージと再試行ボタンを表示します。 role="alert" を使うことで、スクリーンリーダーにもエラーを通知できるんですね。

複数のローディング状態の管理

複数の非同期処理を並行して実行する場合、それぞれのローディング状態を個別に管理する必要があります。

typescript// 複数のローディング状態を持つストアの型定義
interface MultiLoadingStore {
  users: any[];
  posts: any[];
  comments: any[];

  loadingStates: {
    users: boolean;
    posts: boolean;
    comments: boolean;
  };

  fetchUsers: () => Promise<void>;
  fetchPosts: () => Promise<void>;
  fetchComments: () => Promise<void>;
}

各リソースごとに個別のローディング状態を管理することで、部分的な更新が可能になります。

typescript// 複数のローディング状態を管理するストア実装
const useMultiLoadingStore = create<MultiLoadingStore>(
  (set) => ({
    users: [],
    posts: [],
    comments: [],

    loadingStates: {
      users: false,
      posts: false,
      comments: false,
    },

    fetchUsers: async () => {
      set((state) => ({
        loadingStates: {
          ...state.loadingStates,
          users: true,
        },
      }));

      try {
        const response = await fetch('/api/users');
        const users = await response.json();

        set((state) => ({
          users,
          loadingStates: {
            ...state.loadingStates,
            users: false,
          },
        }));
      } catch (error) {
        set((state) => ({
          loadingStates: {
            ...state.loadingStates,
            users: false,
          },
        }));
      }
    },

    // fetchPosts と fetchComments も同様の実装
    fetchPosts: async () => {
      /* 同様の実装 */
    },
    fetchComments: async () => {
      /* 同様の実装 */
    },
  })
);

各 fetch 関数は、対応するローディングフラグのみを更新します。 これにより、他のリソースの読み込み状態に影響を与えずに、独立して処理できるんですね。

以下の表は、エラーハンドリングのベストプラクティスをまとめたものです。

#項目実装方法メリット
1エラーコードの記載Error ${response.status}: ${response.statusText}デバッグが容易になります
2エラー状態の分離ストアに error フィールドを追加エラー表示の一元管理が可能です
3再試行機能clearError + 再実行ボタンユーザーが自己解決できます
4ローディング状態の粒度リソースごとに個別管理部分的な更新が可能です
5アクセシビリティrole="alert" の使用スクリーンリーダー対応できます

具体例

検索フィルタリング機能の実装

大量のデータを検索・フィルタリングする機能を、Zustand と useTransition で実装してみましょう。 この例では、1 万件以上の商品データからリアルタイムに検索する機能を作成します。

データ構造の定義

まずは、商品データの型を定義します。

typescript// 商品データの型定義
interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
  description: string;
  tags: string[];
}

商品には ID、名前、カテゴリ、価格、説明、タグといった属性があります。 これらの属性を使って、多角的な検索を実現できますね。

typescript// 検索ストアの型定義
interface ProductSearchStore {
  // データ
  allProducts: Product[];
  filteredProducts: Product[];

  // 検索条件
  searchQuery: string;
  selectedCategory: string;
  priceRange: { min: number; max: number };

  // UI 状態
  isSearching: boolean;

  // アクション
  setSearchQuery: (query: string) => void;
  setSelectedCategory: (category: string) => void;
  setPriceRange: (range: {
    min: number;
    max: number;
  }) => void;
  performFilter: () => void;
  loadProducts: (products: Product[]) => void;
}

ストアには、全商品データ、フィルタリング後のデータ、検索条件、UI 状態、そして各種アクションを定義しています。

ストアの実装

次に、実際のストアを実装していきます。

typescript// 重いフィルタリング処理の実装
function filterProducts(
  products: Product[],
  query: string,
  category: string,
  priceRange: { min: number; max: number }
): Product[] {
  return products.filter((product) => {
    // テキスト検索(名前、説明、タグから検索)
    const matchesQuery =
      query === '' ||
      product.name
        .toLowerCase()
        .includes(query.toLowerCase()) ||
      product.description
        .toLowerCase()
        .includes(query.toLowerCase()) ||
      product.tags.some((tag) =>
        tag.toLowerCase().includes(query.toLowerCase())
      );

    // カテゴリフィルタ
    const matchesCategory =
      category === '' || product.category === category;

    // 価格範囲フィルタ
    const matchesPrice =
      product.price >= priceRange.min &&
      product.price <= priceRange.max;

    return matchesQuery && matchesCategory && matchesPrice;
  });
}

filterProducts 関数は、複数の条件でフィルタリングを行う重い処理です。 大量のデータを扱う場合、この処理が UI をブロックする原因になりますね。

typescript// ストアの実装
import { create } from 'zustand';

const useProductSearchStore = create<ProductSearchStore>(
  (set, get) => ({
    // 初期状態
    allProducts: [],
    filteredProducts: [],
    searchQuery: '',
    selectedCategory: '',
    priceRange: { min: 0, max: Infinity },
    isSearching: false,

    // 検索クエリを即座に更新(緊急な更新)
    setSearchQuery: (query) => set({ searchQuery: query }),

    // カテゴリを即座に更新(緊急な更新)
    setSelectedCategory: (category) =>
      set({ selectedCategory: category }),

    // 価格範囲を即座に更新(緊急な更新)
    setPriceRange: (range) => set({ priceRange: range }),

    // 続きは次のコードブロックで
  })
);

ここまでで、検索条件を即座に更新するアクションを実装しました。 これらはユーザーの入力に直接対応するため、遅延なく実行される必要があるんです。

typescript// ストアの実装(続き)
const useProductSearchStore = create<ProductSearchStore>(
  (set, get) => ({
    // ...前のコードの続き

    // フィルタリングを実行(非緊急な更新)
    performFilter: () => {
      const {
        allProducts,
        searchQuery,
        selectedCategory,
        priceRange,
      } = get();

      // フィルタリング開始を通知
      set({ isSearching: true });

      // 重いフィルタリング処理
      const filtered = filterProducts(
        allProducts,
        searchQuery,
        selectedCategory,
        priceRange
      );

      // 結果を反映
      set({
        filteredProducts: filtered,
        isSearching: false,
      });
    },

    // 商品データを読み込み
    loadProducts: (products) =>
      set({
        allProducts: products,
        filteredProducts: products,
      }),
  })
);

performFilter は重いフィルタリング処理を実行するアクションです。 この関数は useTransition でラップして呼び出すことで、UI をブロックしないようにします。

コンポーネントの実装

ストアを使用するコンポーネントを実装していきます。

typescript// 検索コンポーネントの import
import { useTransition, useEffect } from 'react';
import { useProductSearchStore } from './store';

必要なフックとストアをインポートします。

typescript// 検索入力コンポーネント
function ProductSearchInput() {
  const [isPending, startTransition] = useTransition();

  const searchQuery = useProductSearchStore(
    (state) => state.searchQuery
  );
  const setSearchQuery = useProductSearchStore(
    (state) => state.setSearchQuery
  );
  const performFilter = useProductSearchStore(
    (state) => state.performFilter
  );
  const isSearching = useProductSearchStore(
    (state) => state.isSearching
  );

  // 検索クエリが変更されたら、フィルタリングを実行
  const handleSearchChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const value = e.target.value;

    // 入力値を即座に反映(緊急な更新)
    setSearchQuery(value);

    // フィルタリングはトランジションでラップ(非緊急な更新)
    startTransition(() => {
      performFilter();
    });
  };

  return (
    <div>
      <input
        type='text'
        value={searchQuery}
        onChange={handleSearchChange}
        placeholder='商品を検索...'
        disabled={isSearching}
      />
      {(isPending || isSearching) && <span>検索中...</span>}
    </div>
  );
}

検索入力コンポーネントでは、入力値の更新とフィルタリング処理を分離しています。 setSearchQuery で即座に入力値を反映し、performFilter はトランジション内で実行することで、入力のレスポンスが保たれるんですね。

typescript// カテゴリフィルタコンポーネント
function CategoryFilter() {
  const [isPending, startTransition] = useTransition();

  const selectedCategory = useProductSearchStore(
    (state) => state.selectedCategory
  );
  const setSelectedCategory = useProductSearchStore(
    (state) => state.setSelectedCategory
  );
  const performFilter = useProductSearchStore(
    (state) => state.performFilter
  );

  const categories = [
    'すべて',
    '家電',
    'ファッション',
    '食品',
    '書籍',
  ];

  const handleCategoryChange = (category: string) => {
    // カテゴリ選択を即座に反映
    setSelectedCategory(
      category === 'すべて' ? '' : category
    );

    // フィルタリングはトランジションでラップ
    startTransition(() => {
      performFilter();
    });
  };

  return (
    <div>
      {categories.map((category) => (
        <button
          key={category}
          onClick={() => handleCategoryChange(category)}
          disabled={isPending}
          style={{
            fontWeight:
              (category === 'すべて' &&
                selectedCategory === '') ||
              category === selectedCategory
                ? 'bold'
                : 'normal',
          }}
        >
          {category}
        </button>
      ))}
    </div>
  );
}

カテゴリフィルタも同様のパターンで実装します。 ボタンのクリックを即座に反映し、フィルタリングはバックグラウンドで実行するんです。

typescript// 価格範囲フィルタコンポーネント
function PriceRangeFilter() {
  const [isPending, startTransition] = useTransition();

  const priceRange = useProductSearchStore(
    (state) => state.priceRange
  );
  const setPriceRange = useProductSearchStore(
    (state) => state.setPriceRange
  );
  const performFilter = useProductSearchStore(
    (state) => state.performFilter
  );

  const handleMinPriceChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const min = Number(e.target.value);

    // 価格範囲を即座に更新
    setPriceRange({ ...priceRange, min });

    // フィルタリングはトランジションでラップ
    startTransition(() => {
      performFilter();
    });
  };

  const handleMaxPriceChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    const max = Number(e.target.value);

    // 価格範囲を即座に更新
    setPriceRange({ ...priceRange, max });

    // フィルタリングはトランジションでラップ
    startTransition(() => {
      performFilter();
    });
  };

  return (
    <div>
      <label>
        最低価格:
        <input
          type='number'
          value={priceRange.min}
          onChange={handleMinPriceChange}
          disabled={isPending}
        />
      </label>
      <label>
        最高価格:
        <input
          type='number'
          value={
            priceRange.max === Infinity
              ? ''
              : priceRange.max
          }
          onChange={handleMaxPriceChange}
          disabled={isPending}
          placeholder='上限なし'
        />
      </label>
    </div>
  );
}

価格範囲フィルタでは、最低価格と最高価格を個別に設定できます。 どちらの入力も即座に反映され、フィルタリングはバックグラウンドで実行されますね。

typescript// 検索結果表示コンポーネント
function ProductSearchResults() {
  const filteredProducts = useProductSearchStore(
    (state) => state.filteredProducts
  );
  const isSearching = useProductSearchStore(
    (state) => state.isSearching
  );

  if (isSearching) {
    return <div>結果を計算中...</div>;
  }

  if (filteredProducts.length === 0) {
    return <div>該当する商品が見つかりませんでした。</div>;
  }

  return (
    <div>
      <p>
        {filteredProducts.length} 件の商品が見つかりました
      </p>
      <div>
        {filteredProducts.map((product) => (
          <div
            key={product.id}
            style={{
              border: '1px solid #ddd',
              padding: '10px',
              margin: '10px 0',
            }}
          >
            <h3>{product.name}</h3>
            <p>カテゴリ: {product.category}</p>
            <p>価格: ¥{product.price.toLocaleString()}</p>
            <p>{product.description}</p>
            <div>
              {product.tags.map((tag) => (
                <span
                  key={tag}
                  style={{
                    marginRight: '5px',
                    padding: '2px 5px',
                    background: '#eee',
                  }}
                >
                  {tag}
                </span>
              ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

検索結果表示コンポーネントでは、フィルタリング後の商品リストを表示します。 isSearching フラグを使って、計算中の状態を適切に表示できるんですね。

typescript// メインコンポーネント
function ProductSearchApp() {
  const loadProducts = useProductSearchStore(
    (state) => state.loadProducts
  );

  // 初回マウント時に商品データを読み込み
  useEffect(() => {
    // 仮想的な大量データの生成(実際は API から取得)
    const generateMockProducts = (): Product[] => {
      const categories = [
        '家電',
        'ファッション',
        '食品',
        '書籍',
      ];
      const products: Product[] = [];

      for (let i = 0; i < 10000; i++) {
        products.push({
          id: `product-${i}`,
          name: `商品 ${i}`,
          category: categories[i % categories.length],
          price: Math.floor(Math.random() * 100000) + 1000,
          description: `これは商品 ${i} の説明文です。`,
          tags: [`タグ${i % 10}`, `タグ${i % 20}`],
        });
      }

      return products;
    };

    loadProducts(generateMockProducts());
  }, [loadProducts]);

  return (
    <div style={{ padding: '20px' }}>
      <h1>商品検索</h1>
      <ProductSearchInput />
      <CategoryFilter />
      <PriceRangeFilter />
      <ProductSearchResults />
    </div>
  );
}

メインコンポーネントでは、初回マウント時に商品データを読み込み、各フィルタコンポーネントと結果表示コンポーネントを配置します。 1 万件のデータでも、useTransition のおかげでスムーズに操作できるんです。

以下の図は、検索フィルタリング機能のデータフローを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Input as 入力コンポーネント
  participant Store as Zustand ストア
  participant Filter as フィルタ処理
  participant Results as 結果表示

  User->>Input: テキスト入力
  Input->>Store: setSearchQuery (即座)
  Input-->>User: 入力反映

  Input->>Filter: startTransition(performFilter)
  Filter->>Store: isSearching = true
  Filter->>Filter: 重いフィルタリング
  Filter->>Store: filteredProducts 更新
  Filter->>Store: isSearching = false
  Store->>Results: 結果表示更新
  Results-->>User: 検索結果表示

図で理解できる要点

  • ユーザー入力は即座に反映され、レスポンスが良好です
  • 重いフィルタリング処理はバックグラウンドで実行されます
  • 処理中も UI は応答性を保ち、他の操作が可能です

無限スクロールリストの実装

次に、無限スクロールでデータを読み込むリスト機能を実装してみましょう。 この例では、スクロールに応じて追加データを取得し、スムーズに表示する機能を作成します。

無限スクロールストアの定義

まずは、無限スクロール用のストアを定義します。

typescript// 無限スクロールストアの型定義
interface InfiniteScrollStore {
  items: any[];
  page: number;
  hasMore: boolean;
  isLoading: boolean;
  error: Error | null;

  loadMore: () => Promise<void>;
  reset: () => void;
}

page で現在のページ番号を管理し、hasMore でさらにデータがあるかどうかを判定します。

typescript// 無限スクロールストアの実装
import { create } from 'zustand';

const ITEMS_PER_PAGE = 20;

const useInfiniteScrollStore = create<InfiniteScrollStore>(
  (set, get) => ({
    items: [],
    page: 1,
    hasMore: true,
    isLoading: false,
    error: null,

    loadMore: async () => {
      const { isLoading, hasMore, page } = get();

      // 既に読み込み中、またはデータがない場合は何もしない
      if (isLoading || !hasMore) return;

      set({ isLoading: true, error: null });

      try {
        // API からデータを取得(仮想的な実装)
        const response = await fetch(
          `/api/items?page=${page}&limit=${ITEMS_PER_PAGE}`
        );

        if (!response.ok) {
          throw new Error(
            `Error ${response.status}: Failed to fetch items`
          );
        }

        const newItems = await response.json();

        set((state) => ({
          items: [...state.items, ...newItems],
          page: state.page + 1,
          hasMore: newItems.length === ITEMS_PER_PAGE,
          isLoading: false,
        }));
      } catch (error) {
        set({
          error:
            error instanceof Error
              ? error
              : new Error('Unknown error'),
          isLoading: false,
        });
      }
    },

    reset: () =>
      set({
        items: [],
        page: 1,
        hasMore: true,
        error: null,
      }),
  })
);

loadMore 関数は、現在のページ番号に基づいてデータを取得し、既存のアイテムに追加します。 取得したデータが ITEMS_PER_PAGE より少ない場合、これ以上データがないと判断するんですね。

無限スクロールコンポーネントの実装

次に、無限スクロールを実装するコンポーネントを作成します。

typescript// 無限スクロールコンポーネントの import
import {
  useTransition,
  useEffect,
  useRef,
  useCallback,
} from 'react';
import { useInfiniteScrollStore } from './store';

必要なフックとストアをインポートします。 useRef はスクロール監視用、useCallback はコールバック関数のメモ化用です。

typescript// 無限スクロールコンポーネント
function InfiniteScrollList() {
  const [isPending, startTransition] = useTransition();

  const items = useInfiniteScrollStore(
    (state) => state.items
  );
  const hasMore = useInfiniteScrollStore(
    (state) => state.hasMore
  );
  const isLoading = useInfiniteScrollStore(
    (state) => state.isLoading
  );
  const error = useInfiniteScrollStore(
    (state) => state.error
  );
  const loadMore = useInfiniteScrollStore(
    (state) => state.loadMore
  );

  // スクロール監視用の ref
  const observerTarget = useRef<HTMLDivElement>(null);

  // 続きは次のコードブロックで
}

Zustand ストアから必要な値を取得し、スクロール監視用の ref を用意します。

typescript// Intersection Observer のセットアップ
function InfiniteScrollList() {
  // ...前のコードの続き

  // スクロール時のコールバック
  const handleObserver = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries;

      // 監視対象が画面内に入り、まだデータがある場合
      if (entry.isIntersecting && hasMore && !isLoading) {
        // トランジションでラップして読み込み
        startTransition(() => {
          loadMore();
        });
      }
    },
    [hasMore, isLoading, loadMore, startTransition]
  );

  // 続きは次のコードブロックで
}

handleObserver コールバックは、監視対象が画面内に入ったときに呼ばれます。 startTransition でラップすることで、データ読み込み中も UI の応答性を保てるんです。

typescript// Intersection Observer の初期化と初回読み込み
function InfiniteScrollList() {
  // ...前のコードの続き

  // Intersection Observer のセットアップ
  useEffect(() => {
    const observer = new IntersectionObserver(
      handleObserver,
      {
        root: null,
        rootMargin: '20px', // 少し手前で読み込み開始
        threshold: 1.0,
      }
    );

    const currentTarget = observerTarget.current;
    if (currentTarget) {
      observer.observe(currentTarget);
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget);
      }
    };
  }, [handleObserver]);

  // 初回マウント時にデータを読み込み
  useEffect(() => {
    if (items.length === 0) {
      startTransition(() => {
        loadMore();
      });
    }
  }, []); // 空配列で初回のみ実行

  // 続きは次のコードブロックで
}

Intersection Observer を使って、監視対象が画面内に入ったことを検知します。 rootMargin: '20px' により、少し手前から読み込みを開始することで、スムーズなスクロール体験を実現できますね。

typescript// JSX のレンダリング部分
function InfiniteScrollList() {
  // ...前のコードの続き

  if (error) {
    return (
      <div role='alert'>
        <h3>エラーが発生しました</h3>
        <p>エラーコード: {error.message}</p>
        <button
          onClick={() => {
            useInfiniteScrollStore.getState().reset();
            startTransition(() => {
              loadMore();
            });
          }}
        >
          再読み込み
        </button>
      </div>
    );
  }

  return (
    <div>
      <div>
        {items.map((item, index) => (
          <div
            key={item.id || index}
            style={{
              padding: '20px',
              borderBottom: '1px solid #ddd',
            }}
          >
            {/* アイテムの内容 */}
            <h3>{item.title}</h3>
            <p>{item.description}</p>
          </div>
        ))}
      </div>

      {/* スクロール監視用の要素 */}
      <div
        ref={observerTarget}
        style={{ height: '20px' }}
      />

      {/* ローディング表示 */}
      {(isLoading || isPending) && (
        <div
          style={{ textAlign: 'center', padding: '20px' }}
        >
          読み込み中...
        </div>
      )}

      {/* これ以上データがない場合 */}
      {!hasMore && items.length > 0 && (
        <div
          style={{ textAlign: 'center', padding: '20px' }}
        >
          すべてのデータを読み込みました
        </div>
      )}
    </div>
  );
}

JSX では、アイテムリストの下に監視用の要素を配置しています。 この要素が画面内に入ると、次のページのデータが読み込まれるんですね。

以下の図は、無限スクロールのデータ読み込みフローを示しています。

mermaidflowchart TD
  A["ページ読み込み"] --> B["初回データ取得"]
  B --> C["アイテム表示"]
  C --> D["ユーザースクロール"]
  D --> E{"監視要素が<br/>画面内?"}
  E -->|No| D
  E -->|Yes| F{"hasMore &&<br/>!isLoading?"}
  F -->|No| D
  F -->|Yes| G["startTransition"]
  G --> H["loadMore()"]
  H --> I["API リクエスト"]
  I --> J["データ追加"]
  J --> K["page + 1"]
  K --> C

  style G fill:#ccccff
  style H fill:#ccffcc

図で理解できる要点

  • スクロールに応じて自動的にデータが読み込まれます
  • startTransition により、読み込み中も UI は応答性を保ちます
  • hasMore フラグで無限ループを防止しています

以下の表は、Zustand と useTransition を使った実装パターンをまとめたものです。

#パターン用途useTransition の役割
1検索フィルタリング大量データの絞り込み重いフィルタ処理を非同期化
2無限スクロール段階的なデータ読み込みデータ取得中の UI 応答性維持
3ソート機能リストの並び替えソート処理のバックグラウンド実行
4タブ切り替えコンテンツの動的読み込み切り替え時の重い処理を遅延
5フォーム送信バリデーション付き送信送信処理中の UI フィードバック

まとめ

Zustand と useTransition の組み合わせは、React 18 の並列レンダリング時代において、最適なユーザー体験を実現するための強力な手段となります。 この記事で解説した内容を振り返ってみましょう。

主要なポイント

Zustand は、シンプルで軽量な状態管理ライブラリとして、hooks ベースの直感的な API を提供してくれます。 一方、useTransition は、状態更新を「緊急」と「非緊急」に分類し、UI の応答性を保ちながら重い処理を実行できる React 18 の新機能ですね。

両者を組み合わせることで、以下のメリットが得られます。

#メリット説明
1UI の応答性向上入力やクリックへの即座のフィードバックが可能です
2パフォーマンス最適化重い処理をバックグラウンドで実行できます
3優先度管理緊急な更新と非緊急な更新を適切に分離できます
4開発体験の向上シンプルな API で複雑な状態管理を実現できます
5スケーラビリティ大規模アプリケーションでも性能を維持できます

実装のベストプラクティス

この記事で紹介した実装パターンを実際のプロジェクトに適用する際は、以下の点に注意してください。

まず、すべての状態更新を useTransition でラップする必要はありません。 ユーザーの入力に直接対応する更新(テキスト入力、クリックフィードバックなど)は、即座に反映すべきです。

一方、重い計算処理やデータ取得、フィルタリングなどの非緊急な更新は、startTransition でラップすることで、UI の応答性を保てます。 この使い分けが、快適なユーザー体験の鍵となるんですね。

エラーハンドリングも重要です。 エラー状態をストアで管理し、エラーコードを含めた詳細なメッセージを表示することで、デバッグが容易になります。 また、再試行機能を提供することで、ユーザーが自己解決できる可能性が高まりますよ。

今後の展望

React の並列レンダリング機能は、今後さらに進化していくでしょう。 Suspense for Data Fetching や Server Components といった新機能との組み合わせにより、より高度な最適化が可能になります。

Zustand も、React の新機能に追随しながら進化を続けています。 シンプルさを保ちながら、新しいユースケースに対応するミドルウェアやヘルパー関数が追加されていくことが期待されますね。

並列レンダリング時代において、Zustand と useTransition の組み合わせは、安全で高速な状態更新を実現するための標準的なパターンとなるでしょう。 この記事で紹介したパターンを、ぜひあなたのプロジェクトに活用してみてください。

関連リンク