mcpField
D
data-fetching

data-fetching

github.com/lobehub開発83.6k812 回インストール今日更新
リポジトリ

data-fetching について

LobeHub の開発者向けに、React アプリのデータ取得を層別に設計するスキルです。サービス層と Zustand ストアの SWR フックを使ったリクエスト、キャッシュ更新、変更処理の規約をまとめています。

主な機能

  1. 01サービス層経由の API 呼び出し
  2. 02Zustand ストアの SWR フック
  3. 03キャッシュ無効化の規約
  4. 04リストと詳細の状態パターン
  5. 05ページネーションと条件付き取得

活用シーン

  1. 01リストと詳細のデータフロー追加
  2. 02useEffect から取得処理を移行
  3. 03変更後の関連キャッシュ更新

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/lobehub/lobehub --skill data-fetching

インストール後、Claude Code で/data-fetchingを入力して読み込まれたか確認します。

同梱ファイルreferences/walkthrough.md

詳細

LobeHub のデータ取得アーキテクチャ

関連情報: store-data-structures では、List と Detail のデータ構造の使い分け(Map と Array)の理由を説明しています。

アーキテクチャの概要

┌─────────────┐
│  Component  │
└──────┬──────┘
       │ 1. Call useFetchXxx hook from store
       ↓
┌──────────────────┐
│  Zustand Store   │
│  (State + Hook)  │
└──────┬───────────┘
       │ 2. useClientDataSWR calls service
       ↓
┌──────────────────┐
│  Service Layer   │
│  (xxxService)    │
└──────┬───────────┘
       │ 3. Call lambdaClient
       ↓
┌──────────────────┐
│  lambdaClient    │
│  (TRPC Client)   │
└──────────────────┘

基本原則

✅ 推奨事項
  1. すべての API 呼び出しにサービスレイヤーを使用する
  2. データ取得には Store SWR Hooks を使用する(useEffect は使用しない)
  3. 適切なデータ構造を使用する — List と Detail のパターンについては、store-data-structures スキルを参照してください
  4. 書き込み操作(作成/更新/削除)には lambdaClient.mutate を使用する
  5. lambdaClient.query はサービスメソッド内でのみ使用する
  6. 命名規則 — 読み取り用フックは useFetchXxx、キャッシュ無効化ヘルパーは refreshXxx とします(例:useFetchBenchmarks / refreshBenchmarks)。ミューテーションでは、サービス呼び出しの後に refreshXxx() を連結します。
❌ 禁止事項
  1. データ取得に useEffect を使用しない
  2. コンポーネントやストアから lambdaClient を直接呼び出さない
  3. サーバーデータに useState を使用しない
  4. データ構造のパターンを混在させない — store-data-structures スキルに従ってください

レイヤー 1:サービスレイヤー

目的
  • lambdaClient へのすべての API 呼び出しをカプセル化する
  • 明確で型付けされたインターフェースを提供する
  • API 操作の単一の信頼できる情報源となる
サービスの構成
// src/services/agentEval.ts
class AgentEvalService {
  // Query methods - READ operations
  async listBenchmarks() {
    return lambdaClient.agentEval.listBenchmarks.query();
  }

  async getBenchmark(id: string) {
    return lambdaClient.agentEval.getBenchmark.query({ id });
  }

  // Mutation methods - WRITE operations
  async createBenchmark(params: CreateBenchmarkParams) {
    return lambdaClient.agentEval.createBenchmark.mutate(params);
  }

  async updateBenchmark(params: UpdateBenchmarkParams) {
    return lambdaClient.agentEval.updateBenchmark.mutate(params);
  }

  async deleteBenchmark(id: string) {
    return lambdaClient.agentEval.deleteBenchmark.mutate({ id });
  }
}

export const agentEvalService = new AgentEvalService();
サービスのガイドライン
  1. ドメインごとに 1 つのサービスを用意する(例:agentEval、ragEval、aiAgent)
  2. シングルトンインスタンスをエクスポートする(export const xxxService = new XxxService())
  3. メソッド名は操作内容に合わせる(list、get、create、update、delete)
  4. パラメーターの型を明確にする(複雑なパラメーターにはインターフェースを使用する)

レイヤー 2:SWR Hooks を備えたストア

目的
  • クライアント側の状態を管理する
  • データ取得用の SWR Hooks を提供する
  • キャッシュの無効化を処理する
状態の構造
// src/store/eval/slices/benchmark/initialState.ts
export interface BenchmarkSliceState {
  // List data - simple array
  benchmarkList: AgentEvalBenchmarkListItem[];
  benchmarkListInit: boolean;

  // Detail data - map for caching
  benchmarkDetailMap: Record<string, AgentEvalBenchmark>;
  loadingBenchmarkDetailIds: string[];

  // Mutation states
  isCreatingBenchmark: boolean;
  isUpdatingBenchmark: boolean;
  isDeletingBenchmark: boolean;
}

initialState、reducer、内部 dispatch のパターンについては、store-data-structures スキルを参照してください。

アクション
// src/store/eval/slices/benchmark/action.ts
const FETCH_BENCHMARKS_KEY = 'FETCH_BENCHMARKS';
const FETCH_BENCHMARK_DETAIL_KEY = 'FETCH_BENCHMARK_DETAIL';

export interface BenchmarkAction {
  // SWR Hooks - for data fetching
  useFetchBenchmarks: () => SWRResponse;
  useFetchBenchmarkDetail: (id?: string) => SWRResponse;

  // Refresh methods - for cache invalidation
  refreshBenchmarks: () => Promise<void>;
  refreshBenchmarkDetail: (id: string) => Promise<void>;

  // Mutation actions
  createBenchmark: (params: CreateParams) => Promise<any>;
  updateBenchmark: (params: UpdateParams) => Promise<void>;
  deleteBenchmark: (id: string) => Promise<void>;

  // Internal methods - not for direct UI use
  internal_dispatchBenchmarkDetail: (payload: BenchmarkDetailDispatch) => void;
  internal_updateBenchmarkDetailLoading: (id: string, loading: boolean) => void;
}

export const createBenchmarkSlice: StateCreator<EvalStore, any, [], BenchmarkAction> = (
  set,
  get,
) => ({
  // Fetch list — simple array stored in benchmarkList
  useFetchBenchmarks: () =>
    useClientDataSWR(FETCH_BENCHMARKS_KEY, () => agentEvalService.listBenchmarks(), {
      onSuccess: (data) => {
        set({ benchmarkList: data, benchmarkListInit: true }, false, 'useFetchBenchmarks/success');
      },
    }),

  // Fetch detail — null key disables the request when id is missing
  useFetchBenchmarkDetail: (id) =>
    useClientDataSWR(
      id ? [FETCH_BENCHMARK_DETAIL_KEY, id] : null,
      () => agentEvalService.getBenchmark(id!),
      {
        onSuccess: (data) => {
          get().internal_dispatchBenchmarkDetail({
            type: 'setBenchmarkDetail',
            id: id!,
            value: data,
          });
          get().internal_updateBenchmarkDetailLoading(id!, false);
        },
      },
    ),

  // Refresh methods
  refreshBenchmarks: () => mutate(FETCH_BENCHMARKS_KEY),
  refreshBenchmarkDetail: (id) => mutate([FETCH_BENCHMARK_DETAIL_KEY, id]),

  // CREATE — refresh list after creation
  createBenchmark: async (params) => {
    set({ isCreatingBenchmark: true }, false, 'createBenchmark/start');
    try {
      const result = await agentEvalService.createBenchmark(params);
      await get().refreshBenchmarks();
      return result;
    } finally {
      set({ isCreatingBenchmark: false }, false, 'createBenchmark/end');
    }
  },

  // UPDATE — optimistic update + refresh
  updateBenchmark: async (params) => {
    const { id } = params;

    // 1. Optimistic update
    get().internal_dispatchBenchmarkDetail({
      type: 'updateBenchmarkDetail',
      id,
      value: params,
    });
    // 2. Set loading
    get().internal_updateBenchmarkDetailLoading(id, true);

    try {
      // 3. Call service
      await agentEvalService.updateBenchmark(params);
      // 4. Refresh from server
      await get().refreshBenchmarks();
      await get().refreshBenchmarkDetail(id);
    } finally {
      get().internal_updateBenchmarkDetailLoading(id, false);
    }
  },

  // DELETE — optimistic update + refresh
  deleteBenchmark: async (id) => {
    get().internal_dispatchBenchmarkDetail({ type: 'deleteBenchmarkDetail', id });
    get().internal_updateBenchmarkDetailLoading(id, true);

    try {
      await agentEvalService.deleteBenchmark(id);
      await get().refreshBenchmarks();
    } finally {
      get().internal_updateBenchmarkDetailLoading(id, false);
    }
  },

  // Internal — dispatch to reducer (for detail map)
  internal_dispatchBenchmarkDetail: (payload) => {
    const currentMap = get().benchmarkDetailMap;
    const nextMap = benchmarkDetailReducer(currentMap, payload);

    // Skip set when nothing changed — avoids unnecessary re-renders
    if (isEqual(nextMap, currentMap)) return;
    set({ benchmarkDetailMap: nextMap }, false, `dispatchBenchmarkDetail/${payload.type}`);
  },

  // Internal — update loading state for specific detail
  internal_updateBenchmarkDetailLoading: (id, loading) => {
    set(
      (state) => ({
        loadingBenchmarkDetailIds: loading
          ? [...state.loadingBenchmarkDetailIds, id]
          : state.loadingBenchmarkDetailIds.filter((i) => i !== id),
      }),
      false,
      'updateBenchmarkDetailLoading',
    );
  },
});
ストアのガイドライン
  1. SWR キーをファイルの先頭で定数として定義する
  2. すべてのデータ取得に useClientDataSWR を使用する(useEffect は使用しない)
  3. onSuccess コールバックでストアの状態を更新する
  4. 更新メソッドでは mutate() を使ってキャッシュを無効化する
  5. ローディング状態を initialState に含め、onSuccess で更新する
  6. ミューテーションではサービスを呼び出してから、関連するキャッシュを更新する

レイヤー 3:コンポーネントでの使用

リストデータの取得
// ✅ CORRECT
const BenchmarkList = () => {
  // 1. Get the hook from store
  const useFetchBenchmarks = useEvalStore((s) => s.useFetchBenchmarks);

  // 2. Get list data
  const benchmarks = useEvalStore((s) => s.benchmarkList);
  const isInit = useEvalStore((s) => s.benchmarkListInit);

  // 3. Call the hook (SWR handles the data fetching)
  useFetchBenchmarks();

  // 4. Use the data
  if (!isInit) return <Loading />;
  return (
    <div>
      <h2>Total: {benchmarks.length}</h2>
      {benchmarks.map((b) => (
        <BenchmarkCard key={b.id} {...b} />
      ))}
    </div>
  );
};
詳細データの取得
// ✅ CORRECT
const BenchmarkDetail = () => {
  const { benchmarkId } = useParams<{ benchmarkId: string }>();

  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);

  // Detail from map
  const benchmark = useEvalStore((s) =>
    benchmarkId ? s.benchmarkDetailMap[benchmarkId] : undefined,
  );

  // Per-item loading
  const isLoading = useEvalStore((s) =>
    benchmarkId ? s.loadingBenchmarkDetailIds.includes(benchmarkId) : false,
  );

  useFetchBenchmarkDetail(benchmarkId);

  if (!benchmark) return <Loading />;
  return (
    <div>
      <h1>{benchmark.name}</h1>
      <p>{benchmark.description}</p>
      {isLoading && <Spinner />}
    </div>
  );
};
セレクターの使用(推奨)
// src/store/eval/slices/benchmark/selectors.ts
export const benchmarkSelectors = {
  getBenchmarkDetail: (id: string) => (s: EvalStore) => s.benchmarkDetailMap[id],
  isLoadingBenchmarkDetail: (id: string) => (s: EvalStore) =>
    s.loadingBenchmarkDetailIds.includes(id),
};

// Component with selectors
const BenchmarkDetail = () => {
  const { benchmarkId } = useParams();
  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
  const benchmark = useEvalStore(benchmarkSelectors.getBenchmarkDetail(benchmarkId!));

  useFetchBenchmarkDetail(benchmarkId);

  return <div>{benchmark && <h1>{benchmark.name}</h1>}</div>;
};
アンチパターン
// ❌ WRONG — Don't use useEffect for data fetching
const BenchmarkList = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    lambdaClient.agentEval.listBenchmarks
      .query()
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return <div>...</div>;
};
コンポーネントでのミューテーション
// Create — global mutation flag drives form loading
const CreateBenchmarkModal = () => {
  const createBenchmark = useEvalStore((s) => s.createBenchmark);
  const isCreating = useEvalStore((s) => s.isCreatingBenchmark);

  const handleSubmit = async (values) => {
    try {
      // Optimistic update + refresh happen inside createBenchmark
      await createBenchmark(values);
      message.success('Created successfully');
      onClose();
    } catch (error) {
      message.error('Failed to create');
    }
  };

  return (
    <Form onSubmit={handleSubmit} loading={isCreating}>
      ...
    </Form>
  );
};

// Update / delete — per-item loading so only the row being mutated spins
const BenchmarkItem = ({ id }: { id: string }) => {
  const updateBenchmark = useEvalStore((s) => s.updateBenchmark);
  const deleteBenchmark = useEvalStore((s) => s.deleteBenchmark);
  const isLoading = useEvalStore(benchmarkSelectors.isLoadingBenchmarkDetail(id));

  const handleUpdate = async (data) => {
    await updateBenchmark({ id, ...data });
  };

  const handleDelete = async () => {
    await deleteBenchmark(id);
  };

  return (
    <div>
      {isLoading && <Spinner />}
      <button onClick={handleUpdate}>Update</button>
      <button onClick={handleDelete}>Delete</button>
    </div>
  );
};

2 つのパターンを使い分ける理由: create ではまだ id がないため、isCreatingXxx フラグ 1 つで十分です。update / delete は特定の行を対象とするため、グローバルなフラグを使うと無関係な行まで操作できなくなります。行ごとの状態は loadingXxxIds で管理してください。


より詳しい実例が必要ですか?

上記の標準的な Benchmark の例は、フラットなリストと詳細データのマップを組み合わせる場合にそのまま使用できます。親 ID をキーにしたリスト(同じ構造が複数の親の下に現れるため、例:datasetMap[benchmarkId])を管理する必要がある場合は、references/walkthrough.md を参照してください。このバリエーションについて、6 つの手順(サービス → reducer → slice → ストアの接続 → セレクター → コンポーネント)をすべて解説しています。


よくあるパターン

パターン 1:ページネーション

キャッシュキーの配列には、再取得を発生させるすべてのパラメーターを含める必要があります。

useFetchTestCases: (params: { datasetId: string; limit: number; offset: number }) =>
  useClientDataSWR(
    params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId, params.limit, params.offset] : null,
    () => agentEvalService.listTestCases(params),
    {
      onSuccess: (data) =>
        set({
          testCaseList: data.data,
          testCaseTotal: data.total,
          isLoadingTestCases: false,
        }),
    },
  );
パターン 2:依存関係のあるデータ取得

どちらのフックも並列で実行されます。SWR がリクエストを重複排除するため、手動で順序を制御する必要はありません。

const BenchmarkDetail = () => {
  const { benchmarkId } = useParams();
  const useFetchBenchmarkDetail = useEvalStore((s) => s.useFetchBenchmarkDetail);
  const useFetchDatasets = useEvalStore((s) => s.useFetchDatasets);

  useFetchBenchmarkDetail(benchmarkId);
  useFetchDatasets(benchmarkId);

  return <div>...</div>;
};
パターン 3:条件付きデータ取得

undefined を渡すと、フック全体が無効になります。

// only fetch when modal is open AND id present
useFetchDatasetDetail(open && datasetId ? datasetId : undefined);
パターン 4:ドメインをまたぐ更新
deleteBenchmark: async (id) => {
  await agentEvalService.deleteBenchmark(id);
  await get().refreshBenchmarks();
  await get().refreshDatasets(id); // related cache invalidated too
};

移行ガイド:useEffect → Store SWR

移行前(❌ 誤り)
const TestCaseList = ({ datasetId }: Props) => {
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    lambdaClient.agentEval.listTestCases
      .query({ datasetId })
      .then((r) => setData(r.data))
      .finally(() => setLoading(false));
  }, [datasetId]);

  return <Table data={data} loading={loading} />;
};
移行後(✅ 正しい)
// 1. Add service method
class AgentEvalService {
  async listTestCases(params: { datasetId: string }) {
    return lambdaClient.agentEval.listTestCases.query(params);
  }
}

// 2. Add store slice hook
export const createTestCaseSlice: StateCreator<...> = (set) => ({
  useFetchTestCases: (params) =>
    useClientDataSWR(
      params.datasetId ? [FETCH_TEST_CASES_KEY, params.datasetId] : null,
      () => agentEvalService.listTestCases(params),
      {
        onSuccess: (data) =>
          set({ testCaseList: data.data, isLoadingTestCases: false }),
      },
    ),
});

// 3. Component reads from store
const TestCaseList = ({ datasetId }: Props) => {
  const useFetchTestCases = useEvalStore((s) => s.useFetchTestCases);
  const data = useEvalStore((s) => s.testCaseList);
  const loading = useEvalStore((s) => s.isLoadingTestCases);

  useFetchTestCases({ datasetId });

  return <Table data={data} loading={loading} />;
};

トラブルシューティング

症状確認項目
データが読み込まれないフックは呼び出されていますか?キーは null/undefined ではありませんか?ネットワークタブにリクエストが表示されていますか?
ミューテーション後に古いデータが表示されるrefreshXxx は実行されましたか?キャッシュキーはフックで使用しているものと一致していますか?
ローディング状態が true のままになるonSuccess で loading=false を設定していますか?Promise がサイレントに拒否されていませんか?
詳細マップにエントリーがないReducer の dispatch は実行されましたか?isEqual が古いデータに対して処理を短絡していませんか?

まとめチェックリスト

新しいデータフェッチを追加する場合:

ステップ 1:型と状態

詳細は store-data-structures を参照してください。

  • @lobechat/types に型を定義します:詳細型+リスト項目型
  • 状態構造:xxxList: XxxListItem[]、xxxDetailMap: Record<string, Xxx>、loadingXxxDetailIds: string[]
  • 楽観的更新が必要な場合は Reducer
ステップ 2:サービスレイヤー
  • src/services/xxxService.ts にサービスを作成します
  • メソッド:listXxx()、getXxx(id)、createXxx()、updateXxx()、deleteXxx()
ステップ 3:ストアアクション
  • 状態構造を initialState.ts に記述します
  • action.ts に以下を記述します:
    • useFetchXxxList()、useFetchXxxDetail(id) — SWR フック
    • refreshXxxList()、refreshXxxDetail(id) — キャッシュの無効化
    • サービスを呼び出す CRUD メソッド
    • Reducer を使用する場合は internal_dispatch、internal_updateLoading
  • selectors.ts(任意ですが、推奨)
  • スライスをメインストアと initialState に統合します
ステップ 4:コンポーネントでの使用
  • ストアフックを使用します(useEffect は使用しません)
  • リストページ:xxxList 配列にアクセスします
  • 詳細ページ:xxxDetailMap[id] にアクセスします
  • UI のフィードバックにローディング状態を使用します

基本的な考え方: 型 → サービス → Reducer → スライス → コンポーネント 🎯


関連スキル

  • store-data-structures — ストア内のリストデータと詳細データの構成方法
  • zustand — Zustand の一般的なパターンとベストプラクティス

コメント