T-CREATOR

SolidJS のサスペンス&非同期 UI 制御

SolidJS のサスペンス&非同期 UI 制御

モダンなWebアプリケーション開発において、非同期処理は欠かすことのできない要素となっています。ユーザーがサイトを訪れた瞬間から、データの取得、API通信、リアルタイム更新など、多くの処理が裏側で実行されているのです。

SolidJSは、このような非同期処理を効率的かつ直感的に扱うために、強力なサスペンス機能を提供しています。従来のフレームワークでは複雑になりがちなローディング状態の管理やエラーハンドリングを、SolidJSではシンプルで読みやすいコードで実現できるのです。

本記事では、SolidJSにおけるサスペンスの仕組みから実践的な活用方法まで、詳しく解説いたします。

背景

従来の非同期 UI 制御の課題

従来のWebアプリケーション開発では、非同期処理による UI 制御に多くの課題がありました。データの取得中にローディング画面を表示し、エラーが発生した場合は適切なメッセージを表示する。一見シンプルに思えるこの処理が、実際のコードでは予想以上に複雑になってしまうのです。

以下のような図で、従来の問題点を整理してみましょう。

mermaidflowchart TD
  start[コンポーネント開始] --> loading[loading状態を管理]
  loading --> api[API呼び出し]
  api --> success{成功?}
  success -->|Yes| data[データを状態に保存]
  success -->|No| error[エラー状態を設定]
  data --> render[UIレンダリング]
  error --> errorUI[エラーUI表示]
  loading --> loadingUI[ローディングUI表示]

このフローからわかるように、開発者は手動で複数の状態を管理し、それぞれに対応するUIを用意する必要がありました。

SolidJS のリアクティブシステムの特徴

SolidJSは、ファイングレインドリアクティビティという独自のアプローチを採用しています。これは、値の変更を検知したときに、その値に依存する部分のみを効率的に更新するシステムです。

この仕組みにより、SolidJSでは以下のような利点が生まれます。

  • 高いパフォーマンス: 必要な部分のみが更新されるため、無駄な再レンダリングが発生しません
  • シンプルな状態管理: Signal を使った直感的な状態管理が可能です
  • 予測可能な動作: データの流れが明確で、デバッグしやすいコードを書けます

サスペンスが解決する問題

SolidJSのサスペンス機能は、非同期処理における複雑な状態管理を大幅に簡素化します。開発者が手動で管理していたローディング状態、エラー状態、データ状態を、フレームワーク側が自動的に処理してくれるのです。

サスペンスによる問題解決を図で表現すると、次のようになります。

mermaidflowchart LR
  component[コンポーネント] --> suspense[Suspense境界]
  suspense --> resource[createResource]
  resource --> api[API呼び出し]
  api --> auto[自動状態管理]
  auto --> ui[適切なUI表示]

この仕組みにより、開発者はビジネスロジックに集中でき、UIの状態管理はフレームワークに任せることができます。

課題

非同期データ読み込み中のローディング状態管理

Webアプリケーションにおいて、データの読み込み中にユーザーに適切なフィードバックを提供することは、良いユーザーエクスペリエンスを実現するために不可欠です。しかし、従来のアプローチでは、以下のような課題がありました。

手動状態管理の複雑性

開発者は、loadingerrordata といった複数の状態を同時に管理する必要がありました。これらの状態は相互に関連しており、一つの状態が変わると他の状態も適切に更新する必要があります。

typescript// 従来のアプローチでの状態管理例
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal(null);
const [data, setData] = createSignal(null);

// データ取得処理
const fetchData = async () => {
  setLoading(true);
  setError(null);
  try {
    const result = await api.getData();
    setData(result);
  } catch (err) {
    setError(err);
  } finally {
    setLoading(false);
  }
};

この例からわかるように、シンプルなデータ取得でも多くのコードが必要になってしまいます。

状態の不整合リスク

複数の状態を手動で管理する場合、状態間の不整合が発生するリスクがあります。例えば、loadingfalse になったにも関わらず、dataerror の両方が設定されてしまうような状況です。

エラーハンドリングの複雑性

非同期処理では、ネットワークエラー、APIエラー、タイムアウトなど、様々な種類のエラーが発生する可能性があります。これらのエラーを適切に処理し、ユーザーにわかりやすいメッセージを表示することは、従来のアプローチでは困難でした。

エラーの種類と対応

エラーの種類発生要因対応方法
ネットワークエラー接続障害、タイムアウト再試行ボタンの表示
APIエラーサーバー側のエラーエラーメッセージの表示
認証エラートークンの期限切れログイン画面への遷移
データ形式エラー予期しないレスポンス開発者向けログ出力

これらの多様なエラーに対して、それぞれ異なる UI と処理ロジックを実装する必要がありました。

ユーザーエクスペリエンスの向上

優れたユーザーエクスペリエンスを提供するためには、以下の要素が重要です。

即座のフィードバック

ユーザーがアクションを起こした瞬間から、何らかのフィードバックを提供する必要があります。データの読み込みが始まったことを示すローディングアニメーション、進捗状況の表示など、ユーザーが待っていることを明確に伝えることが大切です。

段階的な情報表示

大量のデータを一度に表示するのではなく、段階的に情報を表示することで、ユーザーの認知負荷を軽減できます。しかし、この段階的な表示を実装するには、複雑な状態管理が必要になります。

解決策

Suspense コンポーネントの仕組み

SolidJSのSuspenseコンポーネントは、非同期処理を宣言的に扱うための強力な仕組みです。従来の命令的なアプローチとは異なり、「何を表示するか」に焦点を当てた書き方ができます。

Suspenseの基本的な動作原理を図で示します。

mermaidstateDiagram-v2
  [*] --> Pending: リソース読み込み開始
  Pending --> Ready: データ取得完了
  Pending --> Error: エラー発生
  Ready --> Pending: データ再取得
  Error --> Pending: リトライ
  Error --> Ready: 直接データ取得成功

Suspenseコンポーネントは、子コンポーネントで非同期処理が実行されている間、フォールバックUIを自動的に表示します。

typescriptimport { Suspense } from "solid-js";

// 基本的なSuspenseの使用例
function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <DataComponent />
    </Suspense>
  );
}

この例では、DataComponent で非同期処理が実行されている間、「読み込み中...」のメッセージが表示されます。

Suspenseの特徴

  • 自動的な状態管理: ローディング状態を手動で管理する必要がありません
  • 宣言的な記述: UIの構造が明確で理解しやすいコードになります
  • ネスト可能: 複数のSuspense境界を組み合わせて、細かい制御が可能です

createResource による非同期データ管理

createResource は、SolidJSで非同期データを扱うための専用の API です。この関数を使用することで、非同期処理の状態管理が大幅に簡素化されます。

typescriptimport { createResource } from "solid-js";

// ユーザーデータを取得するリソースの作成
const [userData] = createResource(async () => {
  const response = await fetch('/api/user');
  if (!response.ok) {
    throw new Error('ユーザーデータの取得に失敗しました');
  }
  return response.json();
});

createResource の第一引数には、非同期処理を行う関数を渡します。この関数が Promise を返すと、SolidJSが自動的に状態を管理してくれます。

createResourceの返り値

createResource は、以下の要素を含む配列を返します。

typescript// リソースの基本的な使用方法
const [data, { mutate, refetch }] = createResource(fetchFunction);

// dataの状態確認
function DataDisplay() {
  return (
    <div>
      {data.loading && <p>読み込み中...</p>}
      {data.error && <p>エラー: {data.error.message}</p>}
      {data() && <p>データ: {JSON.stringify(data())}</p>}
    </div>
  );
}

パラメータ付きリソース

createResource は、外部の値に依存するリソースも簡単に作成できます。

typescriptimport { createSignal, createResource } from "solid-js";

// ユーザーIDに基づいてデータを取得
const [userId, setUserId] = createSignal(1);

const [userProfile] = createResource(userId, async (id) => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
});

この例では、userId の値が変更されるたびに、新しいデータが自動的に取得されます。

ErrorBoundary との組み合わせ

非同期処理では、エラーハンドリングが重要な要素です。SolidJSでは、ErrorBoundary コンポーネントを使用して、エラーを適切に処理できます。

typescriptimport { ErrorBoundary } from "solid-js";

// エラーハンドリングを含む完全な例
function App() {
  return (
    <ErrorBoundary fallback={(error) => 
      <div>エラーが発生しました: {error.message}</div>
    }>
      <Suspense fallback={<div>読み込み中...</div>}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

エラーバウンダリの階層構造

エラーバウンダリとサスペンスを組み合わせることで、堅牢なアプリケーションを構築できます。

mermaidflowchart TD
  errorBoundary[ErrorBoundary] --> suspense[Suspense]
  suspense --> component[DataComponent]
  component --> error{エラー発生?}
  error -->|Yes| catch[ErrorBoundaryがキャッチ]
  error -->|No| data[データ表示]
  catch --> errorUI[エラーUI表示]

この構造により、エラーが発生した場合でもアプリケーション全体がクラッシュすることを防げます。

カスタムエラーハンドリング

より詳細なエラー処理が必要な場合は、カスタムのエラーハンドラーを実装できます。

typescript// カスタムエラーハンドラーの実装
function CustomErrorBoundary(props) {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <div class="error-container">
          <h2>何らかのエラーが発生しました</h2>
          <p>{error.message}</p>
          <button onClick={reset}>再試行</button>
        </div>
      )}
    >
      {props.children}
    </ErrorBoundary>
  );
}

このカスタムエラーバウンダリでは、エラーメッセージの表示と再試行機能を提供しています。

具体例

基本的なサスペンス実装

まずは、シンプルなデータ取得を例にして、SolidJSでのサスペンス実装を見てみましょう。

ユーザー一覧を表示するコンポーネントを作成します。まず、必要なインポートから始めます。

typescriptimport { createResource, Suspense, ErrorBoundary, For } from "solid-js";
import { Component } from "solid-js";

次に、ユーザーデータを取得する関数を定義します。

typescript// ユーザーデータを取得するAPI関数
async function fetchUsers() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  
  // エラーハンドリング
  if (!response.ok) {
    throw new Error(`HTTP Error: ${response.status}`);
  }
  
  return response.json();
}

リソースを作成し、コンポーネント内で使用します。

typescript// ユーザー一覧コンポーネント
const UserList: Component = () => {
  // createResourceでデータを管理
  const [users] = createResource(fetchUsers);
  
  return (
    <div class="user-list">
      <h2>ユーザー一覧</h2>
      <For each={users()}>
        {(user) => (
          <div class="user-card">
            <h3>{user.name}</h3>
            <p>Email: {user.email}</p>
            <p>Company: {user.company.name}</p>
          </div>
        )}
      </For>
    </div>
  );
};

最後に、SuspenseとErrorBoundaryでラップしたメインコンポーネントを作成します。

typescript// メインアプリケーションコンポーネント
const App: Component = () => {
  return (
    <div class="app">
      <ErrorBoundary 
        fallback={(error) => (
          <div class="error-message">
            <h2>データの取得に失敗しました</h2>
            <p>{error.message}</p>
            <button onClick={() => window.location.reload()}>
              ページを再読み込み
            </button>
          </div>
        )}
      >
        <Suspense 
          fallback={
            <div class="loading">
              <p>ユーザーデータを読み込み中...</p>
              <div class="spinner"></div>
            </div>
          }
        >
          <UserList />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
};

この実装により、以下の流れが自動的に処理されます。

  1. コンポーネントがマウントされると、fetchUsers が実行される
  2. データ取得中は「読み込み中...」メッセージが表示される
  3. データ取得が完了すると、ユーザー一覧が表示される
  4. エラーが発生した場合は、エラーメッセージと再読み込みボタンが表示される

複数のリソースを扱う場合

実際のアプリケーションでは、複数の異なるデータソースから情報を取得することがよくあります。SolidJSでは、複数のリソースを効率的に管理できます。

ユーザー情報と投稿一覧を同時に取得する例を見てみましょう。

typescriptimport { createResource, createSignal, Suspense } from "solid-js";

// ユーザー詳細ページのコンポーネント
const UserDetailPage: Component = () => {
  // 現在表示するユーザーのID
  const [userId, setUserId] = createSignal(1);
  
  // ユーザー情報のリソース
  const [userInfo] = createResource(userId, async (id) => {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  });
  
  // ユーザーの投稿一覧のリソース
  const [userPosts] = createResource(userId, async (id) => {
    const response = await fetch(`/api/users/${id}/posts`);
    return response.json();
  });
  
  return (
    <div class="user-detail">
      <UserSelector userId={userId()} onUserChange={setUserId} />
      
      {/* ユーザー情報セクション */}
      <Suspense fallback={<div>ユーザー情報を読み込み中...</div>}>
        <UserInfoSection user={userInfo()} />
      </Suspense>
      
      {/* 投稿一覧セクション */}
      <Suspense fallback={<div>投稿を読み込み中...</div>}>
        <UserPostsSection posts={userPosts()} />
      </Suspense>
    </div>
  );
};

ユーザー情報表示コンポーネントを定義します。

typescript// ユーザー情報表示コンポーネント
const UserInfoSection: Component<{user: any}> = (props) => {
  return (
    <div class="user-info">
      <h2>{props.user.name}</h2>
      <div class="user-details">
        <p><strong>Email:</strong> {props.user.email}</p>
        <p><strong>Phone:</strong> {props.user.phone}</p>
        <p><strong>Website:</strong> {props.user.website}</p>
        <div class="address">
          <h3>住所</h3>
          <p>{props.user.address.street}, {props.user.address.city}</p>
        </div>
      </div>
    </div>
  );
};

投稿一覧表示コンポーネントを定義します。

typescript// 投稿一覧表示コンポーネント  
const UserPostsSection: Component<{posts: any[]}> = (props) => {
  return (
    <div class="user-posts">
      <h3>投稿一覧</h3>
      <div class="posts-grid">
        <For each={props.posts}>
          {(post) => (
            <article class="post-card">
              <h4>{post.title}</h4>
              <p>{post.body}</p>
            </article>
          )}
        </For>
      </div>
    </div>
  );
};

この実装では、ユーザー情報と投稿一覧が独立して読み込まれます。一方のデータ取得が完了すれば、そちらの UI が先に表示され、もう一方のデータは引き続き読み込み状態を維持します。

ネストしたサスペンス構造

より複雑なアプリケーションでは、サスペンス境界をネストして、きめ細かい制御を行うことができます。

ダッシュボード画面を例に、段階的なデータ読み込みを実装してみましょう。

mermaidflowchart TD
  dashboard[ダッシュボード] --> userInfo[ユーザー情報]
  dashboard --> stats[統計情報]
  dashboard --> notifications[通知]
  userInfo --> profile[プロフィール]
  userInfo --> settings[設定]
  stats --> sales[売上データ]
  stats --> analytics[分析データ]

この構造に対応するコンポーネント実装は以下のようになります。

typescript// メインダッシュボードコンポーネント
const Dashboard: Component = () => {
  return (
    <div class="dashboard">
      <header class="dashboard-header">
        <h1>ダッシュボード</h1>
      </header>
      
      <div class="dashboard-content">
        {/* 最優先で表示するユーザー情報 */}
        <Suspense fallback={<UserInfoSkeleton />}>
          <UserInfoWidget />
        </Suspense>
        
        {/* 統計情報セクション */}
        <div class="stats-section">
          <Suspense fallback={<StatsSkeleton />}>
            <StatsWidget />
          </Suspense>
        </div>
        
        {/* 通知セクション */}
        <aside class="notifications-section">
          <Suspense fallback={<NotificationsSkeleton />}>
            <NotificationsWidget />
          </Suspense>
        </aside>
      </div>
    </div>
  );
};

統計情報ウィジェットでは、さらに詳細なネスト構造を実装します。

typescript// 統計情報ウィジェット(ネストしたサスペンス)
const StatsWidget: Component = () => {
  return (
    <div class="stats-widget">
      <h2>統計情報</h2>
      
      <div class="stats-grid">
        {/* 売上データ */}
        <div class="stats-card">
          <Suspense fallback={<div class="loading-card">売上データ読み込み中...</div>}>
            <SalesChart />
          </Suspense>
        </div>
        
        {/* ユーザー分析 */}
        <div class="stats-card">
          <Suspense fallback={<div class="loading-card">分析データ読み込み中...</div>}>
            <UserAnalytics />
          </Suspense>
        </div>
        
        {/* パフォーマンス指標 */}
        <div class="stats-card">
          <Suspense fallback={<div class="loading-card">指標データ読み込み中...</div>}>
            <PerformanceMetrics />
          </Suspense>
        </div>
      </div>
    </div>
  );
};

各チャートコンポーネントは、独自のデータソースを持ちます。

typescript// 売上チャートコンポーネント
const SalesChart: Component = () => {
  const [salesData] = createResource(async () => {
    // 売上データの取得(重い処理)
    const response = await fetch('/api/analytics/sales?period=30d');
    return response.json();
  });
  
  return (
    <div class="sales-chart">
      <h3>売上推移(30日間)</h3>
      <ChartComponent data={salesData()} />
    </div>
  );
};

// ユーザー分析コンポーネント
const UserAnalytics: Component = () => {
  const [analyticsData] = createResource(async () => {
    // 分析データの取得
    const response = await fetch('/api/analytics/users');
    return response.json();
  });
  
  return (
    <div class="user-analytics">
      <h3>ユーザー分析</h3>
      <AnalyticsChart data={analyticsData()} />
    </div>
  );
};

このネスト構造により、以下のような利点が得られます。

段階的な表示

  • ユーザー情報が最初に表示される
  • 統計情報の各カードが順次表示される
  • 重いデータ処理があっても、他の部分は正常に動作する

細かいフィードバック

  • 各セクションごとに適切なローディング表示
  • エラーが発生しても影響範囲を限定
  • ユーザーは利用可能な情報からすぐに作業を開始できる

まとめ

SolidJS サスペンスの利点

SolidJSのサスペンス機能は、現代的なWebアプリケーション開発において多くの利点をもたらします。最も重要な利点は、開発者の負担軽減です。従来は手動で管理していた複雑な非同期状態を、フレームワークが自動的に処理してくれるため、開発者はビジネスロジックに集中できます。

パフォーマンスの向上も見逃せない利点です。SolidJSのファイングレインドリアクティビティにより、必要な部分のみが更新されるため、大規模なアプリケーションでも高いパフォーマンスを維持できます。また、複数のリソースを並列で取得できるため、ユーザーは最初に利用可能になったデータからすぐに作業を開始できるのです。

宣言的なコード記述により、コードの可読性と保守性が大幅に向上します。UIの構造が明確になり、新しいチームメンバーでも理解しやすいコードベースを構築できます。

エラーハンドリングの統一も重要な利点です。ErrorBoundaryとの組み合わせにより、アプリケーション全体で一貫したエラー処理を実現できます。

実装時の注意点

SolidJSサスペンスを効果的に活用するために、以下の点にご注意ください。

適切な粒度でのサスペンス境界設定

サスペンス境界を細かく設定しすぎると、ローディング表示が頻繁に切り替わり、ユーザーエクスペリエンスが悪化する可能性があります。一方で、境界が大きすぎると、一部のデータ取得が遅れただけで全体の表示が遅くなってしまいます。

ユーザーの利用パターンを考慮して、適切な境界を設定することが重要です。

エラーハンドリングの階層化

ErrorBoundaryは階層的に配置し、エラーの種類に応じて適切なレベルで処理することを推奨します。ページレベル、セクションレベル、コンポーネントレベルでそれぞれ異なる対応を用意することで、ユーザーに分かりやすいエラー表示を提供できます。

リソースの依存関係管理

複数のリソースが相互に依存している場合は、依存関係を明確に定義することが重要です。createResourceの第一引数に依存する値を指定することで、適切なタイミングでデータが再取得されます。

メモリリークの防止

長時間実行されるアプリケーションでは、不要になったリソースを適切にクリーンアップすることが重要です。コンポーネントがアンマウントされる際に、実行中の非同期処理をキャンセルする仕組みを検討してください。

SolidJSのサスペンス機能を適切に活用することで、ユーザーにとって快適で、開発者にとって保守しやすいアプリケーションを構築できます。本記事で紹介した実装例を参考に、皆様のプロジェクトでもぜひ活用してみてください。

関連リンク