tva
← コラム

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

多くの製品のダッシュボードは、同じ方法で構築されています。最上位のコンポーネントが10件のAPI呼び出しを開始し、すべての完了を待ってから一括で表示します。デモでは問題なく見えても、本番環境では時間とともに問題が積み重なります。一つの遅いエンドポイントが画面全体の表示を止め、一つのウィジェットの一時的なエラーで画面全体が使えなくなります。リアルタイム更新には、各処理を連携させたデータの再取得が必要になり、やがて保守できなくなります。新しいウィジェットを加えるときも、拡張を想定していなかったグローバル状態を理解しなければなりません。

TanStack Query(旧React Query)は、データ取得をコンポーネントの状態に後付けする処理ではなく、設計の主要な要素として扱うことで、この問題を解決します。各クエリには独自のキャッシュキー、ライフサイクル、エラー状態があります。ウィジェット型のダッシュボードでは、そのまま各ウィジェットが自分のデータと、読み込み中・エラー・成功の状態を管理する構成になります。アプリケーションの他の部分は、それを意識する必要がありません。

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

データ取得のロジックを書く前に、ウィジェットが取得するデータと、ウィジェットを置く場所を明確に分離する必要があります。両者は独立して変更できます。ユーザーは表示データを変えずにウィジェットを移動でき、バックエンドもレイアウトに影響を与えず新しいデータを返せます。混在させると、後で整合性を保つことが難しくなります。

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;
}

WidgetConfigのqueryKeyフィールドは、TanStack Queryのキャッシュと設定を結び付けます。同じqueryKeyを持つ二つのウィジェットは、一つのネットワークリクエストで取得したキャッシュ結果を共有します。異なるキーを持つ二つのウィジェットは、完全に独立しています。複数のビューで同じ元データを表示する複合ダッシュボードでは、この違いが重要です。

ウィジェットごとのデータ取得

各ウィジェットコンポーネントは、独自の設定で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>
  );
}

読み込み中のスケルトン表示とエラー状態は、意図的に各ウィジェット内で管理します。全体共通の読み込みスピナーや、ウィジェットの失敗を捕捉するアプリケーション全体のエラーハンドラーはありません。各ウィジェットが自分の状態をユーザーに示します。注文ウィジェットが最新データを表示する横で、売上ウィジェットが読み込み中のスケルトンを表示していても、それは不具合ではなく、想定どおりの正しい動作です。

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

useQueryのisError状態で扱えるのはデータ取得のエラーです。コンポーネントの実行中に例外が発生する描画エラーには、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が含まれることが原因です。新しいクエリを実行する再試行で、解消することがよくあります。ただし、バウンダリをリセットしてもウィジェットが再描画されるだけで、それ自体ではデータを再取得しません。キャッシュ内のデータがまだ新鮮であれば、同じデータで再描画されます。新しいクエリで再試行するには、再試行ハンドラー内でウィジェットのクエリもリセットするか、データを再取得してください(例えばqueryClient.resetQueries({ queryKey: config.queryKey })を使います)。クエリエラーをバウンダリにスローする場合は、TanStack QueryのQueryErrorResetBoundaryを使う方法もあります。

ドラッグとリサイズ

ドラッグとリサイズの仕組みは、データ層ほど重要ではありません。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プロパティが重要です。これがないと、ウィジェットのどこをクリックしてもドラッグが始まり、内部のチャート、ボタン、入力フィールドなどの操作と競合します。ドラッグをタイトルバーやグリップアイコンなど、指定したハンドルに限定すれば、この競合を解消できます。

レイアウト状態の永続化

レイアウトの永続化には二つの層があります。ページを再読み込みしても配置が失われないようにする即時保存と、別の端末でも同じ配置を利用できるようにするサーバー側の保存です。実装は異なり、ローカルのコピーとサーバー上のコピーが互いに上書きしないよう、意図的に組み合わせる必要があります。

即時保存には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構成に自然に組み込めます。保存処理をデバウンスすれば、ドラッグ中に1ピクセル動くたびにネットワークリクエストを送るのを防げます。以下のフックは保存するだけです。返されるlayoutsをユーザーが直前に変更した配置と一致させるには、新しいレイアウトをキャッシュにも書き込むか(例えばqueryClient.setQueryData(['dashboard-layout', userId], newLayouts)を使います)、保存に成功した後でクエリを無効化してください。

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と組み合わせることで、ユーザーが作業している間はデータを新しく保ち、離れている間は通信を止めるという、大半の用途に対応できます。

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

ウィジェットの種類と実装を対応付ける共通コンポーネントにより、描画ロジックを一か所にまとめられます。新しい種類を追加するには、コンポーネントを書いて分岐を一つ加えるだけで、他の部分は変わりません。

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設定では、各ウィジェットで上書きできる既定値を定めます。ダッシュボードでは、staleTimeの既定値を30秒にすると、バックグラウンドでの絶え間ない再取得を防ぎながら、ユーザーが離れて戻った際にもデータを十分新しく保てます。

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

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

このアーキテクチャで防げる問題

このアーキテクチャが防ぐ問題は、実現できる機能と同じくらい重要です。読み込み中なのは個々のウィジェットだけで、「すべてのデータ」を一括で読み込む場面はなくなるため、全体共通の読み込み状態は不要になります。連鎖的な障害も大幅に起こりにくくなります。各エラーバウンダリの範囲は一つのウィジェットに限定され、クエリキーが異なるウィジェット同士はデータ取得の状態を共有しないため、一つのウィジェットの描画エラーで他のウィジェットがクラッシュすることはありません。ただし、意図的に同じクエリキーを使うウィジェットは、そのデータとエラー状態も共有するため、そのクエリでの障害は同じキーを使うすべてのウィジェットに影響します。新しいウィジェットの追加時にも、共有状態を変更したり、既存ウィジェットとデータ要件を調整したりする必要がありません。

性能の調査もしやすくなります。TanStack Query DevToolsに表示されるクエリキーを通じて、通信を個々のウィジェットに明確に対応付けられます。過剰なリクエストを送るウィジェットや、実際には使われていないクエリを、ネットワークのウォーターフォールを分析せずに、数秒で見つけられます。

ドラッグとリサイズは、画面を洗練されたものに見せる、目に見える操作機能です。しかし、このデータ基盤に比べれば優先度は低いものです。十分な機能を持つグリッドライブラリなら、配置は処理できます。本番環境でダッシュボードが安定して動くかを決めるのは、データ層が障害時にも使い続けられ、独立して更新できる設計になっているかです。このアーキテクチャなら、そうした設計を無理なく実現できます。

クライアントポータルや社内分析ツールを構築するチームにも、このパターンをそのまま適用できます。ウィジェットという抽象化が役立つために、複雑なドラッグアンドドロップUIは必要ありません。設定可能なデータカードを並べるだけのグリッドでも、クエリの分離とウィジェットごとのエラーバウンダリの効果が得られます。グリッドレイアウトは追加機能であり、基盤となるのはデータのアーキテクチャです。

関連記事

関連記事