data-fetching について
LobeHub の開発者向けに、React アプリのデータ取得を層別に設計するスキルです。サービス層と Zustand ストアの SWR フックを使ったリクエスト、キャッシュ更新、変更処理の規約をまとめています。
主な機能
- 01サービス層経由の API 呼び出し
- 02Zustand ストアの SWR フック
- 03キャッシュ無効化の規約
- 04リストと詳細の状態パターン
- 05ページネーションと条件付き取得
活用シーン
- 01リストと詳細のデータフロー追加
- 02useEffect から取得処理を移行
- 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) │
└──────────────────┘
基本原則
✅ 推奨事項
- すべての API 呼び出しにサービスレイヤーを使用する
- データ取得には Store SWR Hooks を使用する(useEffect は使用しない)
- 適切なデータ構造を使用する — List と Detail のパターンについては、
store-data-structuresスキルを参照してください - 書き込み操作(作成/更新/削除)には
lambdaClient.mutateを使用する lambdaClient.queryはサービスメソッド内でのみ使用する- 命名規則 — 読み取り用フックは
useFetchXxx、キャッシュ無効化ヘルパーはrefreshXxxとします(例:useFetchBenchmarks/refreshBenchmarks)。ミューテーションでは、サービス呼び出しの後にrefreshXxx()を連結します。
❌ 禁止事項
- データ取得に useEffect を使用しない
- コンポーネントやストアから
lambdaClientを直接呼び出さない - サーバーデータに useState を使用しない
- データ構造のパターンを混在させない —
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 つのサービスを用意する(例:agentEval、ragEval、aiAgent)
- シングルトンインスタンスをエクスポートする(
export const xxxService = new XxxService()) - メソッド名は操作内容に合わせる(list、get、create、update、delete)
- パラメーターの型を明確にする(複雑なパラメーターにはインターフェースを使用する)
レイヤー 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',
);
},
});
ストアのガイドライン
- SWR キーをファイルの先頭で定数として定義する
- すべてのデータ取得に
useClientDataSWRを使用する(useEffect は使用しない) - onSuccess コールバックでストアの状態を更新する
- 更新メソッドでは
mutate()を使ってキャッシュを無効化する - ローディング状態を initialState に含め、onSuccess で更新する
- ミューテーションではサービスを呼び出してから、関連するキャッシュを更新する
レイヤー 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 の一般的なパターンとベストプラクティス