tva
← Insights

TanStack Query を使ったカスタマイズ可能なウィジェットダッシュボードの構築

ほとんどのプロダクトダッシュボードは同じ方法で構築されています:トップレベルのコンポーネントが 10 件の API コールを発火させ、すべてを待ち、一度にすべてをレンダリングします。デモでは問題ありません。本番では、時間とともに複合する問題のクラスを生みます。単一の遅いエンドポイントがビュー全体をブロックします。1 つのウィジェットの一時的なエラーが画面全体を壊します。リアルタイム更新は最終的に維持不可能になる協調的な再フェッチを必要とします。新しいウィジェットの追加は、それを収容するよう設計されなかったグローバル状態について推論することを意味します。

TanStack Query(旧 React Query)は、データフェッチをコンポーネント状態に後付けされた余分なものではなく、ファーストクラスの関心事として扱うことでこれを解決します。各クエリは独自のキャッシュキー、ライフサイクル、エラー状態を持ちます。ウィジェットダッシュボードでは、これが直接変換されます:各ウィジェットは自分のデータを所有し、自分の読み込み・エラー・成功状態に責任を持ちます。アプリケーションの残りの部分は知る必要も気にする必要もありません。

レイアウトとデータの分離

データフェッチロジックを書く前に、アーキテクチャは 2 つの関心事の明確な分離を必要とします:ウィジェットが何をフェッチするかとどこに配置されるか。これらは独立して変更可能です — ユーザーは表示するデータを変えずにウィジェットを移動でき、バックエンドはレイアウトに影響を与えずに新しいデータを返すことができます。これらを混同すると、後で調整の問題が生じます。

interface WidgetConfig {
  id: string;
  type: 'revenue' | 'orders' | 'users' | 'activity-feed';
  title: string;
  queryKey: string[];
  refetchInterval?: number;
}

interface WidgetLayout {
  i: string;   // matches WidgetConfig.id
  x: number;
  y: number;
  w: number;
  h: number;
}

WidgetConfigqueryKey フィールドは TanStack Query のキャッシュへの橋渡しです。同じ queryKey を持つ 2 つのウィジェットはキャッシュされた結果を共有します — 1 回のネットワークリクエスト、2 つのコンシューマー。異なるキーを持つ 2 つのウィジェットは完全に独立しています。この区別は、複数のビューが同じ基底データを表示する複合ダッシュボードを構築する際に重要です。

ウィジェットごとのデータフェッチ

各ウィジェットコンポーネントは独自の設定で useQuery を呼び出します。staleTime は TanStack Query にキャッシュされたデータを古いとマークするまでどれだけ現在のものとして扱うかを伝えます。これをウィジェットタイプごとに正しく設定することで、データを適度に新鮮に保ちながら不必要な再フェッチを防ぎます。

function RevenueWidget({ config }: { config: WidgetConfig }) {
  const { data, isLoading, isError } = useQuery({
    queryKey: config.queryKey,
    queryFn: () => fetchRevenue(),
    staleTime: 60_000,
    refetchInterval: config.refetchInterval,
  });

  if (isLoading) return <WidgetSkeleton />;
  if (isError) return <WidgetError />;

  return (
    <div className="widget-content">
      <p className="text-3xl font-bold">{formatCurrency(data.total)}</p>
      <p className="text-sm text-muted">vs last period: {data.changePercent}%</p>
    </div>
  );
}

スケルトンとエラー状態は意図的にウィジェットローカルです。グローバルローディングスピナーはなく、ウィジェットの失敗をキャッチするアプリケーションレベルのエラーハンドラーもありません。各ウィジェットはユーザーに自分の状態を提示します — つまり、ライブデータを表示する注文ウィジェットの隣にローディングスケルトンを表示する収益ウィジェットは、何かが壊れているサインではなく、期待される正しい動作です。

ウィジェットごとのエラーバウンダリ

useQueryisError 状態はフェッチエラーを処理しますが、レンダーエラー — コンポーネント実行中にスローされるもの — は React のエラーバウンダリメカニズムが必要です。各ウィジェットを独自のバウンダリでラップすることで、レンダー中にスローするウィジェットがダッシュボード全体をクラッシュさせません。壊れたものがリカバリー可能なエラー状態を表示している間、ユーザーは他のウィジェットで作業を続けられます。

class WidgetErrorBoundary extends React.Component<
  { children: React.ReactNode; widgetId: string },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="widget-error">
          <p>This widget encountered an error.</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Retry
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Widget({ config }: { config: WidgetConfig }) {
  return (
    <WidgetErrorBoundary widgetId={config.id}>
      <WidgetRouter config={config} />
    </WidgetErrorBoundary>
  );
}

リトライボタンはエラーバウンダリの状態をリセットし、フルページリロードなしにリカバリーへのパスをユーザーに提供します。実際には、ウィジェットコンポーネントのほとんどのレンダーエラーは API からの予期しない null データシェイプが原因です — 新しいクエリをトリガーするリトライはしばしばそれらを解決します。

ドラッグとリサイズ

ドラッグとリサイズの仕組みは最終的にデータ層に対して二次的です。react-grid-layoutは位置決めをうまく処理します:レイアウト配列を受け取り、子要素をグリッドスロットにレンダリングし、ユーザーがウィジェットを移動またはリサイズするたびに onLayoutChange を呼び出します。実装は簡単です。

import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';

function Dashboard({ widgets, layouts, onLayoutChange }: DashboardProps) {
  return (
    <GridLayout
      layout={layouts}
      cols={12}
      rowHeight={60}
      width={1200}
      onLayoutChange={onLayoutChange}
      draggableHandle=".widget-drag-handle"
    >
      {widgets.map(config => (
        <div key={config.id}>
          <div className="widget-drag-handle cursor-grab">
            <span>{config.title}</span>
          </div>
          <Widget config={config} />
        </div>
      ))}
    </GridLayout>
  );
}

draggableHandle プロップは注目に値します。これがないと、ウィジェット上のどこかをクリックするとドラッグが開始され、ウィジェット内のチャート、ボタン、入力フィールドなどのインタラクティブ要素と競合します。ドラッグを指定されたハンドル — タイトルバーやグリップアイコン — に制限することで、インタラクションの競合がクリーンに解決されます。

レイアウト状態の永続化

レイアウトの永続化は 2 層の問題です:ページリフレッシュで配置が破棄されないようにする即時永続化と、レイアウトがデバイスをまたいでユーザーに追従するサーバーサイド永続化です。これらは異なる実装を必要としますが、競合なく構成できます。

即時永続化には、localStorage がうまく機能しネットワークのラウンドトリップが不要です:

function useDashboardLayout(initialLayouts: WidgetLayout[]) {
  const storageKey = 'dashboard-layout';
  const [layouts, setLayouts] = useState<WidgetLayout[]>(() => {
    try {
      const stored = localStorage.getItem(storageKey);
      return stored ? JSON.parse(stored) : initialLayouts;
    } catch {
      return initialLayouts;
    }
  });

  const handleLayoutChange = (newLayouts: WidgetLayout[]) => {
    setLayouts(newLayouts);
    localStorage.setItem(storageKey, JSON.stringify(newLayouts));
  };

  return { layouts, handleLayoutChange };
}

デバイス間の永続化には、レイアウトをサーバー状態として扱う — useQuery でフェッチし useMutation で更新する — ことが既存の TanStack Query セットアップに自然に適合します。保存時のデバウンスにより、ドラッグ中に移動するすべてのピクセルのネットワークリクエストを防ぎます。

function usePersistedLayout(userId: string, initialLayouts: WidgetLayout[]) {
  const { data: layouts } = useQuery({
    queryKey: ['dashboard-layout', userId],
    queryFn: () => fetchUserLayout(userId),
    initialData: initialLayouts,
  });

  const { mutate: saveLayout } = useMutation({
    mutationFn: (newLayouts: WidgetLayout[]) =>
      saveUserLayout(userId, newLayouts),
  });

  const debouncedSave = useMemo(
    () => debounce(saveLayout, 1000),
    [saveLayout]
  );

  return { layouts, handleLayoutChange: debouncedSave };
}

リアルタイム更新

refetchInterval オプションは各ウィジェットに新しいデータをポーリングする頻度の独立した制御を与えます。アクティビティフィードは 10 秒ごとに更新が必要かもしれません。月次収益サマリーは 1 時間に 1 回のリフレッシュで問題ないかもしれません。グローバルポーリング — 一般的な代替手段 — はすべてのウィジェットを同じ間隔に強制し、通常は最も要求の多いウィジェットを満たすための過剰フェッチ、またはフェッチ不足で時間に敏感なウィジェットが陳腐化することを意味します。

const WIDGET_REFRESH_INTERVALS: Record<WidgetConfig['type'], number | undefined> = {
  'activity-feed': 10_000,
  'orders': 30_000,
  'revenue': 3_600_000,
  'users': 300_000,
};

const config: WidgetConfig = {
  id: 'orders-widget',
  type: 'orders',
  title: 'Recent Orders',
  queryKey: ['orders'],
  refetchInterval: WIDGET_REFRESH_INTERVALS['orders'],
};

しかし現実には、無差別なポーリングそれ自体の問題を生みます:短い間隔でポーリングする多くのウィジェットを持つダッシュボードは、ブラウザのタブがバックグラウンドにある場合を含めて相当なネットワークトラフィックを生成します。TanStack Query は refetchIntervalInBackground: false という直接的な解決策を提供し、非表示タブのポーリングを一時停止します。refetchOnWindowFocus: true(デフォルトで有効)と組み合わせることで、ほとんどのユースケースがカバーされます:ユーザーが活発に作業している間はデータが新鮮に保たれ、そうでない間はネットワークリクエストが一時停止されます。

ウィジェットルーターとグローバル設定

ウィジェットタイプを実装にマッピングする中央コンポーネントはレンダリングロジックを 1 か所に保ちます。新しいウィジェットタイプの追加はコンポーネントを書いて 1 つのケースを追加することを意味します — 他は何も変わりません。

function WidgetRouter({ config }: { config: WidgetConfig }) {
  switch (config.type) {
    case 'revenue':      return <RevenueWidget config={config} />;
    case 'orders':       return <OrdersWidget config={config} />;
    case 'users':        return <UsersWidget config={config} />;
    case 'activity-feed': return <ActivityFeedWidget config={config} />;
    default:             return <div>Unknown widget type</div>;
  }
}

グローバル QueryClient 設定は個々のウィジェットがオーバーライドできるデフォルトを設定します。ダッシュボードのコンテキストでは、30 秒のデフォルト staleTime は常時バックグラウンド再フェッチを防ぎながら、ユーザーが離れて戻ってきた後もデータを適度に最新に保ちます。

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 2,
      refetchOnWindowFocus: true,
      refetchIntervalInBackground: false,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
    </QueryClientProvider>
  );
}

このアーキテクチャが防ぐもの

このアーキテクチャが防ぐ問題は、それが実現するものと同様に重要です。グローバルローディング状態は不要になります — 「すべてのデータ」が読み込まれている瞬間はなく、個々のウィジェットのみです。カスケード障害は構造的に不可能になります — 各エラーバウンダリと各クエリが単一のウィジェットにスコープされているため、1 つのウィジェットのエラーが他に伝播することはできません。新しいウィジェットの追加は共有状態に触れることや既存のウィジェットとのデータ要件の調整を必要としません。

パフォーマンスプロファイリングも扱いやすくなります。ネットワークアクティビティは、TanStack Query DevTools に表示されるクエリキーを介して個々のウィジェットに明確に帰属します。1 つのウィジェットが過剰なリクエストをしていること、または特定のクエリが実際に使われていないことを見つけるのは、ネットワークウォーターフォール分析を必要とするのではなく、数秒で済みます。

ドラッグとリサイズの仕組み — ダッシュボードを洗練されたと感じさせる可視でインタラクティブな部分 — は、このデータ基盤に対して真に二次的です。有能なグリッドライブラリならどれも位置決めを処理します。ウィジェットダッシュボードが本番で持ちこたえるかどうかを決定するのは、データ層が適切に失敗し独立して更新するように設計されているかです。このアーキテクチャはその設計を努力を要するものではなく自然なものにします。

クライアントポータルまたは内部分析ツールを構築するチームにとって、このパターンは変更なしに一般化されます。ウィジェットの抽象化は価値を持つために複雑なドラッグアンドドロップ UI を必要としません — シンプルな設定可能なデータカードのグリッドでさえ、クエリの分離とウィジェットごとのエラーバウンダリから恩恵を受けます。グリッドレイアウトは拡張機能であり、データアーキテクチャが基盤です。

関連インサイト

関連記事